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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dec3134e528500e32143217e96b93da16da30322cdf883017db367f645ab1c7d
4
- data.tar.gz: ff6d5f992ea6cb7a5558a447f4256b9885170e4e27e1171c62ecf9c92ac221f6
3
+ metadata.gz: 5c5d8ca344fdd9583bd79ad6e9b162c7a1888d48055dfb36474a3cbc6caf10fe
4
+ data.tar.gz: c903ed3e437da570c06924055d0e1ad1c9c611c0086431ae8c7d38fc000f8337
5
5
  SHA512:
6
- metadata.gz: 8952c2437efb9f8d3d8dafd86c2a09658a178ed657e2e4c632a45bc3be17f965f38d518348dd4b8c17609e7937a1793474b40e68db208cec83ef6f50ce28e4c3
7
- data.tar.gz: aaddf4f15bd7aa7da26f74e4aca681e4c07c0c3baee62bb9107baf8ea5ab5e244a80b84baeec14fdad79935bede5bb0cf00d7796d4c82eac0f01ace5f916371a
6
+ metadata.gz: 749fdfd67d453f51c527eb8e058e5b062d04431d3f099aa35a8b9135a38bd2fdc7baa7251eb0ed7d25775bcb6be7f3370958301a0838d89d7893a37f6dd1306b
7
+ data.tar.gz: 6322de1f369d12c6aff0f187749d4a2f6f090939cce2fb4f4389c9b1a6b7ff1cce3de7d90b4b969706c0997c5d481d8816f45d728dded107445a7455a337f640
data/CHANGELOG.md CHANGED
@@ -8,6 +8,85 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **Multi-field cross-root targets — `reactive_show_targets` speaks the full
12
+ conditions language (#209).** A `"#id"` KEY now takes an `if:`/`if_any:`/
13
+ `unless:` conditions Hash, so an OUTSIDE element can show/hide from a
14
+ COMBINATION of owned fields — `reactive_show_targets("#trade-warning" =>
15
+ { if: { type: "trade", price: ..0 } })` — the last case that forced a
16
+ bespoke two-field JS listener on otherwise-declarative forms. The client
17
+ folds the target's DNF payload with the SAME per-term owned-field reads as
18
+ an in-root `reactive_show` (missing owned field reads blank, fail-closed);
19
+ a target whose referenced fields are ALL unowned is left alone (the
20
+ single-field skip, generalized). Target-keyed and field-keyed entries mix
21
+ in the ONE call per root (a field name may never start with `#`, so the
22
+ key routes unambiguously); existing field-keyed calls emit a byte-identical
23
+ wire. Declare-time validation stays loud: a bare value, unknown keys, or an
24
+ empty conditions Hash under a `"#id"` key raises a guided `ArgumentError`.
25
+
26
+ - **JSON mode for draft nested rows — `reactive_nested_list(:assoc, as: :json)` (#208).**
27
+ The draft-rows primitive (#208) posts standard Rails
28
+ `accepts_nested_attributes_for` names. But many apps persist a "new parent +
29
+ child rows" form by serializing the rows into ONE hidden field and
30
+ `JSON.parse`-ing it in the controller — adopting the primitive meant rewiring
31
+ that persistence path. `as: :json` removes the choice: the list keeps the same
32
+ `<template>` row, `nested_field_name`, and add/remove triggers, but the generic
33
+ controller now mirrors the surviving rows into a single hidden field as a JSON
34
+ array on every add / remove / keystroke (the set-value + dispatch contract, so
35
+ dirty tracking and compute still see it), inferring each JSON key from the
36
+ trailing bracket segment of a row input's name
37
+ (`order[line_items_attributes][3][quantity]` → `"quantity"`). A removed row
38
+ leaves the array (an absent row IS the removal — JSON has no `_destroy`
39
+ marker). The default (`:attributes`) is unchanged, so existing draft forms are
40
+ byte-identical. See the "Draft rows for a new parent" README section and the
41
+ `/docs/example-draft-rows` page.
42
+
43
+ - **Turnkey APM adapters — `Phlex::Reactive.apm = :appsignal` (#207).** The gem
44
+ already emits `*.phlex_reactive` `ActiveSupport::Notifications` events (#107), but
45
+ every reactive action still POSTs to the same endpoint, so an APM rolled all
46
+ reactive traffic into one blurry `ActionsController#create` transaction and an
47
+ action-body crash surfaced as an anonymous 500. One config line closes both gaps:
48
+ - **`Phlex::Reactive.apm = :appsignal` (or `:sentry`, `:datadog`, or a custom
49
+ object)** names each action as its own `Component#action` transaction/span in the
50
+ APM, tagged with the component, action, and outcome. The vendor SDK is
51
+ **runtime-detected** (`defined?(::Appsignal)` …) — never a gemspec dependency, the
52
+ same optionality invariant as pgbus — so a set-but-absent SDK logs one warning and
53
+ no-ops. A custom object need only respond to `record_action(payload, duration_ms)`
54
+ and `record_error(error, payload)`.
55
+ - **Action-body errors are reported with context.** A previously-uncaught error from
56
+ an action body (a genuine 500, not a registered 4xx) is now OBSERVED at the
57
+ endpoint: the `action.phlex_reactive` event is tagged `outcome: :error` (filling the
58
+ #107 nil-outcome gap), the exception is reported to the APM adapter AND every
59
+ registered `Phlex::Reactive.on_action_error { |error, ctx| … }` hook WITH the
60
+ name-only `{ component:, action: }` context, the `error_flash` renders so the actor
61
+ sees a flash on a crash (500s now flow through the same path the 4xx errors used) —
62
+ and THEN the error is **re-raised unchanged**, so Rails' own error reporting and the
63
+ app's middleware fire exactly as before. Each reporter is isolated: a broken reporter
64
+ (or a raising `error_flash` lambda) can never turn one 500 into a different 500 or
65
+ swallow the original. The status never changes; the existing 4xx rescues, their
66
+ statuses, and their outcomes (`invalid_token`/`denied_undeclared`/`not_found`/
67
+ `unauthorized`/`unverified`) are untouched. Payloads and error tags stay name-only —
68
+ never the token, params, or state.
69
+
70
+ - **Draft nested-attribute rows — the "new parent + child rows" form (#208).**
71
+ A collection whose PARENT isn't persisted yet (a new order accumulating line
72
+ items) can't be a reactive collection — an unsaved parent has no gid to sign.
73
+ The pre-save window is now a client-side primitive, the `reactive_tags`
74
+ posture: `reactive_nested_list(:assoc)` / `reactive_nested_template(:assoc)`
75
+ mark the container + the server-owned `<template>` holding ONE row's markup;
76
+ `reactive_nested_add(:assoc)` clones it, swapping every `NEW_ROW` placeholder
77
+ in the clone's `name`/`id`/`for` for a fresh unique index (clock-seeded
78
+ monotonic — server-rendered `0..n` and same-millisecond double clicks can't
79
+ collide) and focusing the new row's first field; `reactive_nested_remove`
80
+ deletes a draft row from the DOM, or `_destroy`-marks + hides a row carrying
81
+ a hidden `[_destroy]` input (an edit form's persisted row) so Rails destroys
82
+ it on save. `nested_field_name(:assoc, :field, index:)` builds the
83
+ scope-aware Rails wire name (`order[line_items_attributes][i][quantity]`).
84
+ Zero round trips, no token (works in a `ClientBindings` component); the REAL
85
+ form submit reconciles parent + rows in ONE request via
86
+ `accepts_nested_attributes_for`. Several collections can share a root
87
+ (association-keyed); the row markup is authored ONCE and serves the template,
88
+ server-rendered edit rows, and the persisted `reactive_collection` flow.
89
+
11
90
  - **`reactive_tags` — the tag-chip input primitive (#203).** The composed
12
91
  combobox/tags widget (preload suggestions, type to narrow, Enter/click to add,
13
92
  remove chips) with zero bespoke JavaScript and zero round trips. The value is
@@ -67,6 +146,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
67
146
 
68
147
  ### Fixed
69
148
 
149
+ - **A draft (unsaved-parent) token can now round-trip real server actions (#208).**
150
+ `Component::Identity` already signed a gid-less `{c, state}` token for an
151
+ unpersisted (or nil) record, but `from_identity` still `fetch`ed the absent
152
+ `gid` — the FIRST action against a draft crashed with an uncaught `KeyError`
153
+ (500). It now rebuilds through the record kwarg's **initialize default**
154
+ (e.g. `def initialize(order: Order.new, …)`), with the declared
155
+ `reactive_state` riding the token as before; a component whose initialize
156
+ REQUIRES the record kwarg raises a guided `Phlex::Reactive::Error` naming the
157
+ fix instead of a bare missing-keyword `ArgumentError`.
158
+
70
159
  - **`have_reactive_value` now reads the field's `.value` property, so it can verify a
71
160
  reducer-set disabled/computed field (#204).** The matcher (added in #201) delegated to
72
161
  Capybara's `have_field(with:)`, which matches the value **attribute** — but a
data/README.md CHANGED
@@ -387,7 +387,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
387
387
  | `reactive_field(:param, **attrs)` | The attribute hash that binds a control to an action param (no magic `name:`) — spread onto any control: `input(**reactive_field(:value, value: @record.name))`, `select(**reactive_field(:status)) { … }`. |
388
388
  | `reactive_text(:name, initial)` | Mirror a compute output (or a declared input) into a **text node** — a live preview heading, a character counter, `"Hello, {name}"` — via `textContent` (XSS-safe). The text sibling of `reactive_field`; carries no `name`, so it's never POSTed. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
389
389
  | `reactive_show(if:/if_any:/unless:)` | **Value-conditional visibility** (the `x-show`/`data-show` case): spread onto the element to show/hide — it toggles `hidden` from the fields' **current values**, client-only, zero round trip. One conditions language: a **Hash is an AND**, an **Array is membership**, a **Range is a threshold**, `if_any:` is OR-of-AND, `unless:` negates. `reactive_values` computes first paint; `disable:` disables a hidden section's controls. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
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). |
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..`); a `"#id"` **key** takes a full conditions Hash for a **multi-field** predicate (`"#warn" => { if: { type: "trade", price: ..0 } }`). 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
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). |
@@ -396,6 +396,9 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
396
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). |
397
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). |
398
398
  | `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
399
+ | `reactive_nested_list(:assoc, as: :attributes \| :json)` / `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). `as: :json` (default `:attributes`) serializes the rows into ONE hidden JSON field instead of posting `accepts_nested_attributes_for` names — for an app whose controller `JSON.parse`s a serialized param. 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`. |
399
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). |
400
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). |
401
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). |
@@ -1016,6 +1019,23 @@ reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
1016
1019
  kind: { "#premium-note" => %w[gold platinum] })
1017
1020
  ```
1018
1021
 
1022
+ **Multi-field targets.** A `"#id"` *key* takes a full `if:`/`if_any:`/`unless:`
1023
+ conditions Hash — the same language `reactive_show` speaks — so a cross-root
1024
+ target can read a **combination** of owned fields (the case that used to force
1025
+ a bespoke two-field JS listener):
1026
+
1027
+ ```ruby
1028
+ reactive_show_targets(
1029
+ "#trade-warning" => { if: { type: "trade", price: ..0 } }, # type == "trade" AND price <= 0
1030
+ mode: { "#advanced-tab" => "advanced" } # field-keyed entries mix in the ONE call
1031
+ )
1032
+ ```
1033
+
1034
+ The fold is identical to an in-root `reactive_show` (each term reads its own
1035
+ field; a missing owned field reads as blank — fail-closed). Every referenced
1036
+ field must be owned by the declaring root; a target whose fields are all
1037
+ unowned is left alone, like the single-field skip.
1038
+
1019
1039
  ### Client-side computes (`reactive_compute` + `reactive_text`)
1020
1040
 
1021
1041
  Some math should feel instant with **no round trip** — a NEW, unsaved record's
@@ -1252,6 +1272,122 @@ The styled, form-builder-integrated version of this widget (label/errors/
1252
1272
  theming) belongs in your form layer — these helpers are deliberately the
1253
1273
  unstyled primitives, like `reactive_filter` before them.
1254
1274
 
1275
+ ### Draft rows for a new parent (`reactive_nested_*`)
1276
+
1277
+ The "new order + line items" form: the user builds up child rows **before the
1278
+ parent exists**. An unsaved parent has no GlobalID to sign, so this pre-save
1279
+ window can't be a reactive collection — and every app ends up hand-writing the
1280
+ same imperative JS (clone a row, renumber indexes, serialize on submit).
1281
+ `reactive_nested_*` is that pattern as a primitive, the `reactive_tags`
1282
+ posture: the rows are **form state**, add/remove run **entirely client-side**
1283
+ (zero round trips, no token — works in a `ClientBindings` component), and the
1284
+ surrounding **real form submit** posts standard Rails
1285
+ `parent[assoc_attributes][<index>][field]` names, so
1286
+ `accepts_nested_attributes_for` creates the parent + rows in **one request**.
1287
+
1288
+ ```ruby
1289
+ class DraftOrderForm < ApplicationComponent
1290
+ include Phlex::Reactive::ClientBindings
1291
+
1292
+ reactive_scope :order # names post as order[line_items_attributes][…]
1293
+
1294
+ def view_template
1295
+ div(**reactive_root(id: "draft_order_form")) do
1296
+ form(action: orders_path, method: "post", data: { turbo: "false" }) do
1297
+ input(**reactive_field(:total, type: "number", value: "0"))
1298
+
1299
+ div(**reactive_nested_list(:line_items)) { } # rows land here
1300
+ template(**reactive_nested_template(:line_items)) { row_fields }
1301
+ button(**reactive_nested_add(:line_items)) { "Add item" }
1302
+
1303
+ button(type: "submit") { "Create order" }
1304
+ end
1305
+ end
1306
+ end
1307
+
1308
+ private
1309
+
1310
+ # ONE row's markup, authored once. The default renders the template
1311
+ # prototype (NEW_ROW placeholder names — the client swaps in a fresh index
1312
+ # per add); pass index: to server-render an edit form's persisted rows.
1313
+ def row_fields(index: nil)
1314
+ kwargs = index.nil? ? {} : { index: }
1315
+ div(**reactive_nested_row) do
1316
+ input(name: nested_field_name(:line_items, :quantity, **kwargs), type: "number")
1317
+ input(name: nested_field_name(:line_items, :price, **kwargs), type: "number")
1318
+ button(**reactive_nested_remove) { "×" }
1319
+ end
1320
+ end
1321
+ end
1322
+ ```
1323
+
1324
+ ```ruby
1325
+ # The model + controller side is plain Rails — the reconcile is one create:
1326
+ class Order < ApplicationRecord
1327
+ has_many :line_items, dependent: :destroy
1328
+ accepts_nested_attributes_for :line_items, allow_destroy: true
1329
+ end
1330
+
1331
+ Order.create!(params.require(:order).permit(:total,
1332
+ line_items_attributes: %i[id quantity price _destroy]))
1333
+ ```
1334
+
1335
+ Clicking add clones your `<template>` row and swaps every `NEW_ROW` in the
1336
+ clone's `name`/`id`/`for` attributes for a fresh unique index (clock-seeded and
1337
+ strictly monotonic — server-rendered `0..n` indexes and same-millisecond double
1338
+ clicks can't collide), then focuses the new row's first field. Remove on a
1339
+ draft row deletes it from the DOM — it was never persisted, so removing its
1340
+ fields *is* the removal. Remove on a row carrying a hidden `[_destroy]` input
1341
+ (an **edit** form's persisted row, rendered with `nested_field_name(:line_items,
1342
+ :_destroy, index: i)`) marks it `"1"` and hides the row instead — Rails destroys
1343
+ it on save. Several collections can share one root (everything is keyed by the
1344
+ association name); nesting a collection inside another's template is not
1345
+ supported.
1346
+
1347
+ Two boundaries to respect: the DOM is the single source of truth for unsent
1348
+ draft rows, so a **server re-render of the root replaces them** — keep
1349
+ replace-shaped actions out of a root holding unsent rows. And once the parent
1350
+ is saved, the persisted flow takes over: the same row markup renders with real
1351
+ indexes, or the list graduates to a [reactive collection](#reactive-collections-addremove-rows--count--empty-state)
1352
+ (`reactive_collection` + `reply.append`/`reply.remove`).
1353
+
1354
+ **JSON mode — one hidden field instead of `accepts_nested_attributes_for`.**
1355
+ If your controller already parses a **serialized JSON param** (the app-rolled
1356
+ "stuff the rows into a hidden field, `JSON.parse` on submit" pattern) rather
1357
+ than nested attributes, opt the list into `as: :json` and keep your persistence
1358
+ path exactly as it is:
1359
+
1360
+ ```ruby
1361
+ div(**reactive_nested_list(:line_items, as: :json)) { } # + a hidden field to sync
1362
+ # the hidden field the client keeps in sync (seed "[]" so an empty submit posts one):
1363
+ input(type: "hidden", **reactive_field(:line_items), value: "[]")
1364
+ ```
1365
+
1366
+ ```ruby
1367
+ # The controller stays hand-rolled — NO accepts_nested_attributes_for:
1368
+ rows = JSON.parse(params.require(:order).permit(:line_items)[:line_items].presence || "[]")
1369
+ order = Order.create!(total: params[:order][:total])
1370
+ rows.each { order.line_items.create!(quantity: it["quantity"], price: it["price"]) }
1371
+ ```
1372
+
1373
+ Everything else is identical — the same `<template>` row, the same
1374
+ `nested_field_name`, the same add/remove triggers. In JSON mode the client
1375
+ mirrors the surviving rows into that one hidden field as a JSON array on every
1376
+ add / remove / keystroke (the set-value + dispatch contract, so dirty tracking
1377
+ and compute still see it), inferring each JSON key from the **trailing bracket
1378
+ segment** of a row input's name (`order[line_items_attributes][3][quantity]` →
1379
+ `"quantity"`). A removed row simply leaves the array (JSON carries no `_destroy`
1380
+ marker — an absent row *is* the removal). The per-row `_attributes` names still
1381
+ render but are ignored by a controller that doesn't permit them; the JSON field
1382
+ is the single source of truth.
1383
+
1384
+ Relatedly, a **draft parent can now run real server actions too** (issue #208):
1385
+ an unsaved record signs a gid-less `{c, state}` token, and the endpoint rebuilds
1386
+ the component through the record kwarg's **initialize default** —
1387
+ `def initialize(order: Order.new, …)` — with the declared `reactive_state`
1388
+ riding the token. A component whose initialize *requires* the record kwarg
1389
+ raises a guided error on the first draft action, telling you to add the default.
1390
+
1255
1391
  **Combining `on(...)` / `reactive_attrs` with your own attributes.** Both return
1256
1392
  a hash that includes a `data:` key. Spreading them *and* passing another `data:`
1257
1393
  (or `class:`, `id:`) would clobber it — use Phlex's `mix` to deep-merge. For the
@@ -2002,10 +2138,11 @@ Phlex::Reactive.action_path = "/_r/actions"
2002
2138
  Phlex::Reactive.verbose_errors = true
2003
2139
 
2004
2140
  # User-visible flash on endpoint failures (default nil = off). When set, every
2005
- # rescue path (400/403/404) ALSO renders a turbo-stream flash the user sees — at
2006
- # the SAME status it returns today (statuses never change). The lambda receives
2007
- # the failure kind (:tampered/:unknown_class/:not_reactive_class/:forbidden/
2008
- # :not_found), so you can map it to a friendly message:
2141
+ # rescue path (400/403/404 AND a 500 crash) ALSO renders a turbo-stream flash the
2142
+ # user sees — at the SAME status it returns today (statuses never change). The
2143
+ # lambda receives the failure kind (:tampered/:unknown_class/:not_reactive_class/
2144
+ # :forbidden/:not_found, or :error for an action-body crash), so you can map it to
2145
+ # a friendly message:
2009
2146
  Phlex::Reactive.error_flash = ->(kind) do
2010
2147
  case kind
2011
2148
  when :not_found then "That item is no longer available."
@@ -2014,6 +2151,17 @@ Phlex::Reactive.error_flash = ->(kind) do
2014
2151
  end
2015
2152
  end
2016
2153
 
2154
+ # Turnkey APM integration. Names each action Component#action in AppSignal/Sentry/
2155
+ # Datadog and reports action-body crashes with component/action tags. SDK is
2156
+ # runtime-detected (no gem dependency); a custom object responding to
2157
+ # record_action/record_error works too. See "Observability" above.
2158
+ Phlex::Reactive.apm = :appsignal
2159
+
2160
+ # Report action-body crashes to any tracker yourself (the DIY escape hatch):
2161
+ Phlex::Reactive.on_action_error do |error, ctx|
2162
+ Honeybadger.notify(error, context: { component: ctx[:component], action: ctx[:action] })
2163
+ end
2164
+
2017
2165
  # Component-aware wrapper around every action (audit / rate-limit / assert).
2018
2166
  # Sees the resolved component, action name, and COERCED params; runs inside
2019
2167
  # the connection-id scope but OUTSIDE the transaction. See "Two seams" below.
@@ -2293,6 +2441,50 @@ The events fire whether or not you enable the LogSubscriber; the flag only
2293
2441
  controls the gem's own log lines. See
2294
2442
  [docs/performance.md](https://phlex-reactive.zoolutions.llc/docs/performance).
2295
2443
 
2444
+ ### Turnkey APM adapters (AppSignal, Sentry, Datadog)
2445
+
2446
+ Subscribing by hand is the DIY path. For the common trackers there's a one-liner
2447
+ that both names the transaction and reports errors — so reactive traffic stops
2448
+ rolling into one blurry `ActionsController#create` and a crash arrives at your
2449
+ tracker with context:
2450
+
2451
+ ```ruby
2452
+ # config/initializers/phlex_reactive.rb
2453
+ Phlex::Reactive.apm = :appsignal # or :sentry, :datadog, or a custom object
2454
+ ```
2455
+
2456
+ With it set:
2457
+
2458
+ - Each reactive action shows in the APM as its OWN transaction/span —
2459
+ `Counter#increment`, not `Phlex::Reactive::ActionsController#create` — tagged
2460
+ with the component, action, and outcome.
2461
+ - An action body that raises a genuine error (a 500, not a registered 4xx) is
2462
+ **reported to the tracker with `component`/`action` tags**, then re-raised
2463
+ unchanged so Rails' own error reporting still fires.
2464
+
2465
+ The SDK is **runtime-detected** — no gem dependency is added. If the named SDK
2466
+ isn't loaded, `apm =` logs one warning at boot and no-ops (the same optionality
2467
+ invariant as pgbus). A custom object works too — anything responding to
2468
+ `record_action(payload, duration_ms)` and `record_error(error, payload)`.
2469
+
2470
+ For a tracker with no built-in adapter, report errors yourself — this hook fires
2471
+ on any previously-uncaught action-body error, with the name-only context:
2472
+
2473
+ ```ruby
2474
+ Phlex::Reactive.on_action_error do |error, ctx|
2475
+ Honeybadger.notify(error, context: { component: ctx[:component], action: ctx[:action] })
2476
+ end
2477
+ ```
2478
+
2479
+ And to show the user a flash when an action crashes (the SAME hook the 4xx errors
2480
+ already use — `kind` is `:error` for a crash):
2481
+
2482
+ ```ruby
2483
+ Phlex::Reactive.error_flash = ->(kind) { "Something went wrong — please retry." }
2484
+ ```
2485
+
2486
+ See [docs/observability.md](https://phlex-reactive.zoolutions.llc/docs/observability).
2487
+
2296
2488
  ### Client debug mode (devtools-lite)
2297
2489
 
2298
2490
  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 —