phlex-reactive 0.13.4 → 0.14.0

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1050 -630
  3. data/README.md +561 -14
  4. data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
  5. data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
  6. data/app/javascript/phlex/reactive/core.js +181 -0
  7. data/app/javascript/phlex/reactive/core.min.js +4 -0
  8. data/app/javascript/phlex/reactive/core.min.js.map +11 -0
  9. data/app/javascript/phlex/reactive/early.js +175 -0
  10. data/app/javascript/phlex/reactive/early.min.js +4 -0
  11. data/app/javascript/phlex/reactive/early.min.js.map +10 -0
  12. data/app/javascript/phlex/reactive/features/bindings.js +1511 -0
  13. data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
  14. data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
  15. data/app/javascript/phlex/reactive/features/compute.js +541 -0
  16. data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
  17. data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
  18. data/app/javascript/phlex/reactive/features/defer.js +726 -0
  19. data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
  20. data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
  21. data/app/javascript/phlex/reactive/features/devtools.js +242 -0
  22. data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
  23. data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
  24. data/app/javascript/phlex/reactive/features/effects.js +362 -0
  25. data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
  26. data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
  27. data/app/javascript/phlex/reactive/features/form.js +216 -0
  28. data/app/javascript/phlex/reactive/features/form.min.js +4 -0
  29. data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
  30. data/app/javascript/phlex/reactive/features/hints.js +193 -0
  31. data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
  32. data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
  33. data/app/javascript/phlex/reactive/features/persist.js +663 -0
  34. data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
  35. data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
  36. data/app/javascript/phlex/reactive/reactive_controller.js +85 -5821
  37. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  38. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
  39. data/app/javascript/phlex/reactive/runtime.js +3690 -0
  40. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
  41. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
  42. data/lib/phlex/reactive/component/dsl.rb +74 -2
  43. data/lib/phlex/reactive/component/helpers.rb +39 -9
  44. data/lib/phlex/reactive/component/lazy.rb +138 -2
  45. data/lib/phlex/reactive/component/registry.rb +2 -0
  46. data/lib/phlex/reactive/component/selection.rb +71 -0
  47. data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
  48. data/lib/phlex/reactive/doctor.rb +166 -8
  49. data/lib/phlex/reactive/dormant.rb +66 -0
  50. data/lib/phlex/reactive/engine.rb +63 -3
  51. data/lib/phlex/reactive/fragment.rb +233 -0
  52. data/lib/phlex/reactive/inspector/report.rb +24 -3
  53. data/lib/phlex/reactive/inspector.rb +23 -2
  54. data/lib/phlex/reactive/js.rb +17 -2
  55. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
  56. data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
  57. data/lib/phlex/reactive/param_schema.rb +68 -54
  58. data/lib/phlex/reactive/show_conditions.rb +84 -56
  59. data/lib/phlex/reactive/streamable.rb +20 -2
  60. data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
  61. data/lib/phlex/reactive/test_helpers/system.rb +62 -0
  62. data/lib/phlex/reactive/version.rb +1 -1
  63. data/lib/phlex/reactive.rb +84 -0
  64. data/lib/tasks/phlex_reactive.rake +6 -2
  65. metadata +38 -1
data/CHANGELOG.md CHANGED
@@ -6,9 +6,155 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.14.0] - 2026-10-07
10
+
11
+ This section also covers 0.10.0 through 0.13.4, which were released without
12
+ changelog sections of their own.
9
13
 
10
14
  ### Added
11
15
 
16
+ - **Bulk-selection lists without a per-list controller (#319).**
17
+ `reactive_select_all("ids[]")` makes a header box that ticks or unticks every
18
+ owned box of the group (dispatching `input` and `change` on each one it flips) and shows
19
+ checked / indeterminate / unchecked from the group. `reactive_count("ids[]")`
20
+ writes the ticked count. `reactive_enable(if:/if_any:/unless:)` is
21
+ `reactive_show`'s sibling that flips the element's own `disabled` (a button,
22
+ a fieldset), with the same server first paint. The conditions language gains
23
+ `{ checked: n }` / `{ checked: 1.. }` — the ticked count of a checkbox group,
24
+ in every binding that takes conditions, evaluated for first paint from
25
+ `reactive_values` (an Array of checked values, or a count). Rows appended or
26
+ removed by a stream re-sync the header, the count, the show/enable bindings
27
+ and show targets, and re-arm `reactive_on_complete` without firing it; a
28
+ header sets every box before dispatching, so a count condition sees the
29
+ final count; a nested root's boxes are never counted or flipped. `js.submit(to,
30
+ submitter: "#delete-submit")` submits through that submit control, so its
31
+ `name=value` posts; anything that is not a submit control of the form warns
32
+ and falls back to a plain submit. The client grows by about 0.8 KB gzip
33
+ (the default bundle 24,976 → 25,786 B).
34
+
35
+ - **`phlex_reactive:doctor` flags Falcon under `:thread` isolation (#321).**
36
+ Falcon serves each request as a fiber on one thread, so under Rails' default
37
+ `config.active_support.isolation_level = :thread` every piece of thread-keyed
38
+ state (`CurrentAttributes`, `IsolatedExecutionState`, thread variables, the
39
+ transactional-test connection lock) is shared between requests. A new
40
+ `fiber_isolation` check fails when Falcon serves the app and the level is
41
+ `:thread`, with the fix (`config.active_support.isolation_level = :fiber` in
42
+ `config/application.rb`); it passes under `:fiber` and for Puma or any other
43
+ server. The server is read from the bundle and the server constants already
44
+ loaded, never by requiring one; when Falcon is bundled beside another server
45
+ and is not the only server loaded, the line is advisory. The installation docs gain a
46
+ "Running under Falcon" section.
47
+
48
+ - **A hotkey pressed before the controller connects is no longer lost (#303).**
49
+ `phlex/reactive/early` now records `window:` triggers — how a hotkey is
50
+ usually bound (`on(:toggle, event: "keydown.k", window: true)`,
51
+ `on_client("keydown.k", …, window: true)`) — by a capture listener on
52
+ `window`, and the controller replays them on connect. Such a keypress also
53
+ wakes a dormant root. It is never prevented (a window binding is not prevented
54
+ once connected either), and it is replayed only if the controller connects
55
+ within 1.5 s of the keypress, or the early-event TTL if that is shorter.
56
+ `outside:` triggers and non-bubbling events from inside the page are not
57
+ recorded (Stimulus's window listener never hears the latter). Two roots
58
+ sharing a hotkey both hear it, as they do once connected. `early.min.js`
59
+ grows from 1,060 B to 1,296 B gzipped; its test budget moves from 1,100 to
60
+ 1,300 B. The replay side adds about 210 B gzipped to both the default bundle
61
+ and the opt-in core.
62
+ - **`phlex_reactive:doctor` finds the early import anywhere and checks every import map (#307).** The dormant-roots check now scans every app JS entry and inline module scripts in `app/views` and `app/components` (naming the file), and a new advisory line per import map lacking the `phlex/reactive/early` pin; set `Phlex::Reactive.importmaps = -> { { "landing" => map } }` for maps outside `Rails.application.importmap`.
63
+ - **`reactive_lazy(cache:)` — privately cacheable lazy renders (#277).**
64
+ `reactive_lazy cache: { max_age: 10.minutes }` (combinable with `on:` and
65
+ `tag:`) renders a stable signed URL into the shell and the client GETs it; the
66
+ new `GET /reactive/fragment/:id` answers `Cache-Control: max-age=…, private`
67
+ with an ETag, so the browser reuses the fragment instead of asking again. With
68
+ `on:` that is "load on first open, reuse across page views", and a refresh
69
+ morph re-materializes from the browser cache. By default the reply says
70
+ `Vary: Cookie`; define `reactive_cache_viewer` to key the URL on the viewer
71
+ instead (needed for reuse across page views under Rails' cookie session store,
72
+ whose cookie changes on every response) and `reactive_cache_version` to bust
73
+ it. Every non-200 reply is `no-store`, a component without `cache:` is 404, the
74
+ id is purpose-scoped (not an identity or defer token), a blank
75
+ `reactive_cache_viewer` falls back to `Vary: Cookie` (never a shared anonymous
76
+ key) and `u` is a keyed digest, a cacheable reply carries no `Set-Cookie` (a
77
+ request during which any callback changed the session, set or consumed a
78
+ flash, minted a CSRF token, or wrote a cookie is `no-store` instead; a filter
79
+ can tighten the policy — `no_store`, `expires_now`, a shorter `max-age`,
80
+ `must-revalidate`, and `max-age=0` means `no-store` — never loosen it; the
81
+ ETag is the body only, so `etag { }` blocks do not apply to fragments), a redirected or non-turbo-stream reply is a failed load,
82
+ and
83
+ a render that embeds a form authenticity token is served `no-store`. New
84
+ settings: `Phlex::Reactive.fragment_path` (if you change it, also render
85
+ `<meta name="phlex-reactive-fragment-path">`), and
86
+ `Phlex::Reactive.fragment_cache_max_age_limit` (1 hour). `phlex_reactive:doctor`
87
+ checks the GET route. Not breaking: opt-in.
88
+
89
+ - **`reactive_lazy(on:)` — load on an event or on visibility (#276).**
90
+ `reactive_lazy on: "panel:opened"` keeps the placeholder shell until that
91
+ event reaches it (bubbling from inside counts), then fetches the real render
92
+ once; `on: :visible` (or `on: { visible: "200px" }`) does the same when the
93
+ shell first enters the observer region (the viewport, grown by any
94
+ rootMargin). No request on page load, no re-arm after the
95
+ replace, and no TTL: the shell carries the identity token and a
96
+ framework-owned `__materialize` trigger, which the action endpoint answers
97
+ with the real render (403 for components that aren't `reactive_lazy(on:)`).
98
+ `action :__materialize` is now refused at declaration. A Turbo morph that
99
+ turns the loaded root back into the shell re-materializes it at once, and a
100
+ shell that is still unloaded after a morph is re-armed (a failed load's retry
101
+ path). Plain `reactive_lazy` is unchanged.
102
+
103
+ - **`phlex/reactive/early`: triggers that fire before the controller connects
104
+ are replayed, not lost (#273).** Import it eagerly
105
+ (`import "phlex/reactive/early"`; the engine pins it with `preload: true`) and
106
+ the `reactive` controller may load lazily: a click, submit, key or custom
107
+ event that reaches an element-bound `on`/`on_client` trigger (not a `window:`
108
+ or `outside:` one) before its root connects is
109
+ queued — its native default stopped where the controller would stop it — and
110
+ replayed once on connect. `:once` triggers replay once. Entries older than
111
+ `Phlex::Reactive.early_event_ttl_ms` (10 s, read from
112
+ `<meta name="phlex-reactive-early-ttl">`) or whose element left the page are
113
+ dropped, with a warning under `data-reactive-verbose`. The module is under
114
+ 1.1 KB gzipped, asserted by a test. Every root now also sets
115
+ `data-reactive-connected` and emits a bubbling `reactive:connect` event when
116
+ its controller connects. Apps that never import `early` see no change beyond
117
+ that event and attribute. If you add the import, remove any hand-written
118
+ "wait for the controller, then dispatch" loop, or the trigger fires twice.
119
+
120
+ - **Dormant roots: mount the controller on first use (#274).**
121
+ `reactive_dormant` (class-level, inherited) or `reactive_root(dormant: true)`
122
+ / `reactive_attrs(dormant: true)` renders `data-reactive-dormant="reactive"`
123
+ in place of `data-controller="reactive"`, so a root that only matters after a
124
+ gesture neither mounts the controller nor, when it loads lazily, fetches it
125
+ (68 KB minified, 20 KB gzipped). The first element-bound `on`/`on_client`
126
+ trigger (not a `window:` or `outside:` one) wakes the
127
+ root: `phlex/reactive/early` moves the identifier into `data-controller`
128
+ (other controllers on the root stay connected) and the trigger is replayed
129
+ once on connect. Requires `import "phlex/reactive/early"`;
130
+ `phlex_reactive:doctor` lists the dormant components and whether it found
131
+ the import. The actor's reply, a `reactive_lazy(on:)` materialize and the
132
+ defer endpoint render the root awake; the page and broadcasts render it
133
+ dormant, and a dormant render landing on an awake root is re-woken by the
134
+ next trigger. A `reactive_lazy(on: "event")` shell can be dormant (the event
135
+ wakes and loads it in one request); `on: :visible` cannot, and declaring both
136
+ raises. Non-dormant roots render the same
137
+ bytes as before. `early.min.js` grows 53 B (1,007 → 1,060 B gzipped; the test
138
+ budget moves from 1,024 to 1,100 B).
139
+
140
+ - **`phlex_reactive:actions` tells a deliberate skip from a forgotten check
141
+ (#278).** The `auth` column now has three states: `authorized*` (a call was
142
+ detected), `skipped` / `skipped (class)` (declared `skip_verify_authorized`,
143
+ per action or component-wide, inherited too) and `unverified` (neither).
144
+ `UNVERIFIED=1` lists only the unverified rows. The JSON and the MCP
145
+ `phlex_reactive_actions` / `phlex_reactive_find` tools add `authorization`
146
+ (`detected`/`skipped`/`none`) and `authorization_skip` (`class`/`action`/`null`);
147
+ `authorization_call_detected` stays for compatibility.
148
+
149
+ - **System-test helpers that count reactive requests (#279).**
150
+ `reset_reactive_requests!`, `have_reactive_requests(n, kind: nil)` and
151
+ `reactive_request_count` in `TestHelpers::System` answer "one request on
152
+ first open, none after" without a fetch spy. The matcher waits until the
153
+ layer is idle, then asserts the exact count (`kind: :action` or `:defer`),
154
+ failing at once if the count overshoots. The client keeps per-kind totals on
155
+ `<html data-reactive-requests>` only under the verbose gate (dev/test by
156
+ default); production writes nothing.
157
+
12
158
  - **Several `on_client` bindings on one element (#271).** Two `on_client`
13
159
  calls merged with `mix` now both work — e.g. a root that closes a menu on an
14
160
  outside click AND on Escape. Each call carries its own descriptor, flags and
@@ -474,324 +620,352 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
474
620
  `StaleReferenceError` or read a transient blank — the matcher waits for the value
475
621
  to settle.
476
622
 
477
- ### Fixed
478
-
479
- - **`reactive_compute` never saw a checkbox's checked state (#262).**
480
- `#recompute` resolved each declared name first-wins and read `.value`, which
481
- for a checkbox is a constant. Measured: a Rails `check_box` pair read `0`
482
- ticked or not (the hidden companion comes first), a lone `value="1"` box read
483
- `1` either way, and a box with no value attribute read `0` (`Number("on")` is
484
- NaN). A radio group read its FIRST radio's value, whichever was checked. The
485
- reducer ran on the toggle, from values that could not have changed.
486
-
487
- A checkbox now contributes its checked state, coerced by the declared type:
488
- `1`/`0` as a `:number` (and in the array form), `"true"`/`"false"` as a
489
- `:string`, and `true`/`false` under the new **`:boolean`** type. It wins over
490
- its same-named hidden companion, as it already did for `reactive_show`. A radio
491
- group contributes its checked radio's value. The identity mirror and the
492
- `mirror:` fallback paint the same reading.
623
+ - **Conditional confirm — warn only when the values look suspect (#179).**
624
+ `confirm:` now takes a Hash for soft-validation-before-submit, so the dialog fires
625
+ ONLY when the field values are wrong — instead of a hand-written submit handler that
626
+ inspects fields and calls `confirm()` itself. Two forms, both evaluated client-side
627
+ over the same collected fields `reactive_compute` reads:
628
+ - **Declarative** — `confirm: { when: { total: 0 }, message: "Total is 0 — continue?" }`.
629
+ `when:` reuses `reactive_show`'s conditions language verbatim: a scalar is equals,
630
+ a `Range` is a threshold (`qty: 100..`), an `Array` is membership. Zero JS. The
631
+ dialog fires when the condition MATCHES; a clean value submits silently.
632
+ - **Named predicate** — `confirm: { predicate: "end_before_start", message: "…" }` for
633
+ multi-field logic the single-field form can't express. Register a pure function at
634
+ boot (`setConfirmPredicate("end_before_start", ({ starts_at, ends_at }) => ends_at < starts_at)`),
635
+ the twin of `setComputeReducer`. An unregistered name warns and proceeds without a
636
+ dialog. Works on `on(...)` AND `on_client(...)`. The predicate is soft-validation UX,
637
+ **not authorization** — a user can bypass it and the action still hits the endpoint's
638
+ real authorize/default-deny; never let it stand in for a server-side check.
493
639
 
494
- Outputs were wrong the same way: a result whose name resolved to a checkbox
495
- pair wrote `.value` on the hidden companion, changing what the UNCHECKED state
496
- submits and leaving the box alone. An output now sets a checkbox's `checked`
497
- from the result's truthiness (`""`, `"0"`, `"false"`, `0` and `false` untick)
498
- and checks the radio of a group that carries the result.
640
+ - **`confirm:` on `on_client(...)` — themed confirmation for zero-round-trip client ops (#178).**
641
+ The client-op path gains the SAME overridable `confirmResolver` gate `on(:action, confirm:)`
642
+ has (#52/#55). A destructive-feeling client op (clear a draft, reset a form) gets the
643
+ app's themed dialog with one line and no round trip:
644
+ `button(**on_client(:click, js.text("#draft", ""), confirm: "Discard this draft?"))`.
645
+ The gate lives in the user-gesture path (`runOps`), never in the shared op applier — a
646
+ server-pushed `reactive:js` op stream must not prompt. `setConfirmResolver` now themes
647
+ both paths at once.
499
648
 
500
- **If a reducer relied on the old reading**, it changes: a lone checkbox with a
501
- numeric `value` used to read that number regardless of state and now reads
502
- `1`/`0` — write the amount in the reducer (`gift ? 25 : 0`). Apps that worked
503
- around the defect by reading the box from `document` inside the reducer keep
504
- working and can drop the workaround.
649
+ - **CI transport verification matrix — the browser suite runs on Action Cable AND pgbus (#187).**
650
+ The system job is now `server × transport` (Puma/Falcon × cable/pgbus). The pgbus
651
+ cells add a plain `postgres:18` (pgbus vendors the PGMQ schema via its own
652
+ migrations — no extension image) and set `TRANSPORT=pgbus`, so the existing browser
653
+ suite proves the reactive round trip is transport-agnostic, and new `:pgbus`-tagged
654
+ specs prove real cross-tab broadcast delivery + actor-echo exclusion over live
655
+ Postgres SSE. `rake spec:system_matrix` runs the 2×2 locally (pgbus cells skip with
656
+ a note when Postgres isn't reachable); `rake pgbus:prepare_test_db` sets up the DB.
657
+ pgbus remains an optional, non-gemspec dependency — it is now a first-class *tested*
658
+ transport, not a required one.
505
659
 
506
- Cost, same machine, happy-dom (engine-relative): the 30-input calculator
507
- bench goes from 16.3 to 18.4 µs/iter, allocations flat. That is one `el.type`
508
- read per declared name, which is what telling a checkbox from a text field
509
- takes.
660
+ - **Compound & numeric `reactive_show` predicates — `all:`/`any:` and
661
+ `gte:`/`gt:`/`lte:`/`lt:` (#176).** Value-conditional visibility now spans
662
+ **more than one field** and **numeric thresholds**, staying inside the
663
+ eval-free "declared literal predicate" contract. `all:` / `any:` fold a list of
664
+ per-field terms (`{ field:, equals:/not:/in:/gte:/… }`) with one fixed
665
+ connective — AND vs OR over the same literal vocabulary, no expression surface;
666
+ one flat binding replaces wrapper-div nesting and is the only way to express OR.
667
+ `gte:`/`gt:`/`lte:`/`lt:` compare `Number(value)` against a literal number baked
668
+ into the binding (the RHS must be a real `Numeric` — a typo fails at render); a
669
+ non-numeric field value is `NaN` → hidden, the safe reveal-on-threshold default.
670
+ Numeric predicates work standalone, as a compound term, and inside a
671
+ `reactive_show_targets` map. Malformed terms fold **false** (fail-closed:
672
+ default-deny). The single-field `reactive_show(:field, equals:)` form is
673
+ unchanged; the additions are backwards compatible.
510
674
 
511
- - **The dropped-param hints said nothing for a schema with string keys.**
512
- `ParamSchema.compile` keeps keys as the author wrote them, so
513
- `params: { "date" => :string }` is valid, but the #16/#21 hints only looked
514
- up symbol keys. The param was still dropped and logged, just without the hint
515
- that says where the schema declares it.
675
+ - **Installable Claude debugging skill + `rails g phlex:reactive:claude` (#168).**
676
+ The gem ships a `phlex-reactive-debugging` skill (the doctor → inventory → find
677
+ → browser `report()` → MCP workflow + a failure table) under `lib/`, and the
678
+ new generator copies it into a host app's `.claude/skills/` and writes the MCP
679
+ server entry to `.mcp.json` — only when absent (it never rewrites an existing
680
+ `.mcp.json`; it prints the snippet instead). A new **Debugging & tooling** docs
681
+ page ties the four surfaces together; the security page documents
682
+ `verify_authorized`; the instrumentation table gains the `:unverified` outcome;
683
+ the README gains a Debugging & tooling section + the config rows.
516
684
 
517
- - **A checkbox group collapsed to one boolean, and the chosen values never left
518
- the browser (#258).** `#collectFields` wrote `fields[name] = field.checked` for
519
- every checkbox, so several boxes sharing a `features[]` name overwrote each
520
- other and the action received the LAST box's checked state — `{}` under a
521
- `[:string]` schema, `"false"` under a flat `:string` one, silent either way,
522
- while a native submission of the same boxes sends
523
- `features[]=news&features[]=events`. A name ending in `[]` is now collected as
524
- an array of the chosen values: a ticked box contributes its `value`, an
525
- unticked one nothing, a `<select multiple>` its selected options. The suffix is
526
- the only trigger — a group says so rather than being inferred from two controls
527
- sharing a name.
685
+ - **On-demand client inspector — `phlex/reactive/inspect` (#168).** A standalone
686
+ JS module (the `confirm.js`/`compute.js` precedent — **zero hot-path cost**, no
687
+ edit to `reactive_controller.js`, loaded only when imported) that scans the live
688
+ DOM and maps every reactive root + bound trigger back to the server
689
+ `Component#action` names. From the browser console:
690
+ `(await import("phlex/reactive/inspect")).report()` prints a `console.table` of
691
+ every reactive root — its `id`, decoded token payload (component class, gid,
692
+ state keys, token version — try/catch base64+JSON decode, degrading to
693
+ `{ opaque: true }` on a Marshal-serialized payload), status attrs, triggers
694
+ (action + event + params + debounce/throttle/confirm), client-only ops,
695
+ computes, the `name`d fields the dispatch would collect, and the
696
+ show/filter/text binding families. Triggers are scoped to the **nearest** root,
697
+ so nested roots aren't double-attributed. The server↔client mapping is
698
+ by-name: `scan()`'s `component` + trigger `action` strings are exactly the
699
+ identifiers `phlex_reactive:actions` and the MCP tools list. Pinned by the
700
+ engine (not preloaded); pure read, never mutates the page.
528
701
 
529
- **That makes the suffix a migration point.** ANY `[]`-named control now
530
- contributes to an array, including a single one and including a named rich
531
- editor or bare contenteditable, which the collector reads in a second pass: a
532
- lone `<input type="text" name="tags[]">` used to post `"abc"` and now posts
533
- `["abc"]`. An editor sharing its `[]` name with another control adds its
534
- value to the group instead of standing down behind it, so the entry count
535
- changes there too. Beside a radio it still stands down, because a radio keeps
536
- its single value with or without the suffix. A ready but empty editor
537
- contributes an empty string, the way an empty text field in the same group
538
- does. An unticked box still contributes nothing. A hidden input under the
539
- same name keeps contributing: nothing in the DOM tells a hidden that mirrors
540
- an editor from one that is a list JS maintains, and a value posted twice is
541
- visible where a swallowed one is not. Against a flat `params: { tags: :string
542
- }` the array coerces to the literal `"[\"abc\"]"` — silently, with a 200. A
543
- control whose name ends in `[]` has to be declared as an array type (`tags:
544
- [:string]`), or renamed without the suffix if it was never meant as a list.
702
+ - **Read-only diagnostic MCP server (#168).** `bin/rails phlex_reactive:mcp`
703
+ starts a stdio [MCP](https://modelcontextprotocol.io) server exposing five
704
+ read-only tools — `phlex_reactive_doctor`, `phlex_reactive_components`,
705
+ `phlex_reactive_actions` (optional `component:` filter), `phlex_reactive_find`
706
+ (fuzzy search + Prism method source), `phlex_reactive_config` (redacted) — so
707
+ Claude Code inside a host app can introspect the live reactive registry when
708
+ debugging. The `mcp` gem is **optional and lazy**: it is NOT a gemspec runtime
709
+ dependency; `Phlex::Reactive::MCP.load!` requires it on demand with a helpful
710
+ message when missing (the pgbus pattern), and the gem-dependent tool tree stays
711
+ out of the Zeitwerk autoloader — a host app without `mcp` boots and eager-loads
712
+ unaffected. Every tool is read-only and non-destructive (no arbitrary-query or
713
+ mutation tool) and reports names/paths/schemas only — never a token, secret, or
714
+ runtime state; `phlex_reactive_config` never emits the verifier or
715
+ `secret_key_base`. Consumer `.mcp.json`:
716
+ `{ "mcpServers": { "phlex-reactive": { "command": "bin/rails", "args": ["phlex_reactive:mcp"] } } }`.
717
+ Known constraint: stdio MCP needs a clean stdout — an initializer that `puts`
718
+ breaks the transport (same caveat as pgbus).
545
719
 
546
- Three shapes keep their meaning on purpose: a lone checkbox without `[]` stays
547
- the documented yes/no boolean, a radio group keeps its single checked value
548
- with or without the suffix, and a hidden input sharing a name with a checkbox
549
- is that box's companion. The companion is identified by the shared name, not
550
- by its value, because Rails renders three of them: `check_box` emits
551
- `value="0"`, `check_box(..., multiple: true)` the same under a `[]` name, and
552
- `collection_check_boxes` a blank one — or none at all with
553
- `unchecked_value: nil`. Reading the second shape by value collected
554
- `["0","0","0","3"]` for three boxes with the third ticked.
720
+ - **verify_authorized runtime guard (#168).** New
721
+ `Phlex::Reactive::Authorization` (fiber-local tracking window, method
722
+ interception, the enforcement decision), `mark_authorized!` instance helper,
723
+ the `skip_verify_authorized` DSL (registry #6, inherits like the other five),
724
+ and `Phlex::Reactive.verify_authorized` / `authorization_methods` config
725
+ (`defined?`-guarded so an explicit override sticks). See the breaking note
726
+ above for the behavior and remedies.
555
727
 
556
- A form body can't carry an empty array, so a cleared group used to go missing
557
- whenever the client sent a form body (as soon as a file input holds a file),
558
- and the action got its keyword default instead of `[]`. Now the client names
559
- the cleared group in `empty_groups[]`, a field next to `token`, `act` and
560
- `params`, and the endpoint sets it to `[]`. A request without that field
561
- behaves as before, and a group that carries values keeps them.
728
+ - **Action inventory — `Phlex::Reactive::Inspector` + rake tasks (#168).** A new
729
+ read-only introspection layer that answers "what reactive actions exist in this
730
+ app, where are they defined, and is each authorized?" without grepping.
731
+ `Phlex::Reactive::Inspector.components` discovers every constant-backed reactive
732
+ component from the loaded `Streamable` registry; `.find(query)` fuzzy-matches
733
+ one (exact > prefix > substring > subsequence, on both the demodulized and the
734
+ full name). Each action reports its declared param schema, `file:line`, the full
735
+ `def … end` source (extracted with **Prism**, degrading to `nil` on an
736
+ unreadable/unparseable file — never raising), and a **heuristic** authorization
737
+ status (a Prism scan for a configured authorization method or
738
+ `mark_authorized!` in the body — advisory only, since a helper may authorize
739
+ indirectly). Two shipped rake tasks surface it: `bin/rails
740
+ phlex_reactive:actions` (plain-text table, `FORMAT=json` for tooling) and
741
+ `bin/rails "phlex_reactive:find[query]"` (ranked matches; top match in detail
742
+ with each action's method source). Output is names/paths/schemas only — never
743
+ tokens, secrets, or runtime state (the instrumentation privacy contract
744
+ extended to tooling). `Doctor` now delegates its `constant_backed_component?`
745
+ filter to the Inspector so the endpoint-rebuild predicate lives in one place.
562
746
 
563
- It applies to params the action declares as an array, by plain name or with
564
- the component's `reactive_scope` in front, and only at the top level. A group
565
- declared one level down or inside nested attributes is ignored and keeps its
566
- keyword default; `verbose_errors` logs it. The endpoint never takes the key it
567
- writes from the request. It looks the name up in the declaration and writes
568
- the declared key, so a request can't create a param the action didn't ask for.
747
+ - **Deferred reply segments — `reply.defer` (#165).** An expensive part of a
748
+ reply (a cross-aggregate rollup, a report) no longer stalls the actor's
749
+ interaction: `reply.streams(cheap).defer(SessionTotals.new(workout:))`
750
+ returns the cheap streams immediately and streams the real render to the
751
+ SAME actor when it finishes. Keep-content default (the stale value stays
752
+ visible, marked `data-reactive-defer-pending` + `aria-busy` for CSS
753
+ shimmer); `placeholder: true` / a component swaps a skeleton in;
754
+ `morph: true` morphs the arrival (the mode rides INSIDE the signed token).
755
+ Transactional (the directive rides the post-commit reply — a rollback or a
756
+ denied action leaks nothing), actor-scoped (peers keep `broadcast_*_to`),
757
+ superseding (a newer action for the same target aborts the in-flight
758
+ deferred render — no stale paint), and interactive on arrival (fresh action
759
+ token). Delivery is transport-adaptive (`Phlex::Reactive.defer_transport`,
760
+ default `:auto`): a parallel fetch to the new `POST /reactive/defer`
761
+ endpoint everywhere (purpose-scoped, short-TTL defer token —
762
+ `defer_token_ttl`, default 120s; `reply.defer` tokens are **actor-bound**
763
+ to the requesting session so a leaked one can't be redeemed elsewhere,
764
+ `reactive_lazy` shell tokens are unbound by necessity — they render
765
+ before a session exists — with the TTL + `authorize!` as their bound;
766
+ never interchangeable with action tokens),
767
+ or a **durable pgbus one-shot stream + `DeferredRenderJob`** when pgbus's
768
+ reactive Streams and ActiveJob are present (`defer_job_queue` config; the
769
+ durable since-id replay closes the broadcast-before-subscribe race, and the
770
+ broadcast tears down its own subscription). The push lane's one-shot queue is
771
+ reclaimed by pgbus's age-based orphan-stream sweep (**pgbus ≥ 0.9.10**; run
772
+ the Dispatcher with `streams_orphan_threshold` set); we never eager-drop it
773
+ (that would reopen the delivery race). The one-shot key is sized to the live
774
+ pgbus `queue_prefix` budget, so a non-default prefix can't overflow it; the
775
+ render job broadcasts a cleanup on ANY failure so the actor's pending state
776
+ always resolves. Every capability gap degrades to the fetch lane — the
777
+ Action-Cable-or-pgbus invariant holds. **Profile first:** an app-side N+1
778
+ looks exactly like framework lag; defer is for segments that are genuinely
779
+ expensive after the synchronous path is cheap.
569
780
 
570
- A blank entry (`params[name][]=""`) would have been the other way, but the
571
- schema reads `[""]` per element type, and for a `[:file]` param behind
572
- `has_many_attached` that is the difference between "not sent" and "remove the
573
- attachments".
781
+ - **Lazy initial mount — `reactive_lazy` (#165).** The same machinery for the
782
+ FIRST render (Livewire `#[Lazy]`): the page ships the component's
783
+ placeholder shell (`deferred_placeholder`, or a built-in pending shell) with
784
+ the defer token on the root; the client fetches the real content on connect
785
+ AND after a Turbo page-refresh morph (so a lazy component survives a
786
+ `turbo:reload`). `reactive_lazy tag: :tr` (etc.) ships a shell element that
787
+ matches a `<tr>`/`<li>` root instead of an invalid `<div>`.
788
+ Reactive-machinery renders (an action's self-replace, broadcasts, the defer
789
+ endpoint/job) stay REAL, so actions never pay two round trips.
574
790
 
575
- `post_reactive_multipart` takes `empty_groups:` so a request spec can send a
576
- cleared group the way the client does. `ParamSchema#array_param(name)`
577
- returns the declared key for an array param, or `nil`.
791
+ - **pgbus capability gates.** `Phlex::Reactive.pgbus?` and `.pgbus_streams?`
792
+ (the documented broadcast-accepts-`:exclude` probe, now actually
793
+ implemented) plus `.defer_push_capable?` for the defer push lane.
578
794
 
579
- `reactive_persist` drafts such a group as the list of ticked values and
580
- restores exactly those boxes; before, the draft held one boolean and the
581
- restore ticked every box of the group. Whether the server already rendered a
582
- box ticked — in which case it keeps its say and the draft yields — is decided
583
- once before the restore walks the controls, because the walk writes `checked`
584
- as it goes and asking from inside it would read the restore's own work: the
585
- first box it ticks would make every later box of the group look
586
- server-rendered, and a draft of two values would come back as one. A
587
- non-checkbox control sharing the group's name additionally threw inside the
588
- draft write, which is swallowed — the root then persisted nothing at all,
589
- silently. On restore, such a control keeps what the server rendered whenever
590
- the group has two or more contributors: the list records the values, not
591
- which control each one came from, so replaying it would paste `freeform,news`
592
- into a text field, an editor, or a contenteditable. A group of ONE
593
- contributor has no such ambiguity — its single entry can only have come from
594
- that control — so a plain field whose name merely ends in `[]`, the usual
595
- shape for a list JS maintains, keeps its draft exactly as it did before
596
- groups existed. A `<select multiple>` reads a list by matching option values,
597
- which is only sound when the list is its own: sharing a group with another
598
- contributor, it too keeps what the server rendered, since a text value that
599
- happens to equal an option would otherwise select it.
600
-
601
- Drafts written before this release are not discarded, but their group key is
602
- no longer applied to the controls that read a list: it holds one boolean (or,
603
- in a mixed group, whichever control wrote last), and applying that kept
604
- causing damage for as long as the draft lived — by default seven days after
605
- the upgrade. The damage differed by control. A checkbox group came back fully
606
- ticked. A `<select multiple>` sharing the group's name lost its rendered
607
- selection instead, because under `restore: "always"` the select branch skips
608
- the "the server had a say" check and matches `Set{"true"}` against its
609
- options, where nothing matches. The next snapshot replaces the key with the
610
- list. Only that one key changed meaning, which is why `PERSIST_VERSION` stays
611
- where it is: bumping it would also throw away the drafted prose of every form
612
- that has no checkbox group at all.
613
-
614
- - **`reply.pending` kept its settle handle under
615
- `enqueue_after_transaction_commit = true` (#254).** The handle was captured in
616
- `Phlex::Reactive::Settles#serialize`, which reads a thread-local that lives
617
- only for the duration of `reply.pending`'s enqueue block. Under Rails'
618
- `ActiveJob::Base.enqueue_after_transaction_commit = true` (the 7.2+
619
- recommended setting) `job.enqueue` is deferred to
620
- `ActiveRecord.after_all_transactions_commit`, and the endpoint runs every
621
- action inside a transaction — so `serialize` always ran AFTER the block had
622
- exited. The job serialized with no `phlex_reactive_settle` key,
623
- `reactive_settle` was a no-op, and the row sat shimmering until someone
624
- reloaded. The handle is now captured when the job INSTANCE is created
625
- (`perform_later` → `job_or_instantiate` → `new`), which is synchronous inside
626
- the block whether or not the enqueue itself is deferred; `serialize` prefers
627
- the captured handle and still falls back to the thread-local. Both the
628
- `job:`/`args:` sugar (narrowing per record, so failures stay attributable) and
629
- the block form are covered, and a retry re-enqueue keeps its handle. Jobs
630
- enqueued outside any `reply.pending` still carry nothing.
631
-
632
- - **`rake release` bumps the pin in every tracked lockfile (#247, #253).** Since
633
- #246 the gem root's `Gemfile.lock` is committed alongside `docs/Gemfile.lock`,
634
- and both pin `phlex-reactive` by local path — so a version bump that left them
635
- alone shipped a stale lockfile, and the Release workflow's frozen
636
- `bundle install` refused it (v0.13.0's first attempt).
637
-
638
- The task now bumps that pin **with a text edit**, not `bundle lock`. #247's
639
- first cut used `bundle lock --local`, which is a full re-resolve — and a
640
- re-resolve trips over constraints that have nothing to do with this gem:
641
- `docs/Gemfile.lock` declares Linux platforms for the Kamal deploy, and
642
- resolving `thruster` for those against a Mac's installed gems fails ("Could
643
- not find gems matching 'thruster' valid for all resolution platforms"), which
644
- aborted v0.13.1's first attempt mid-release with `version.rb` already bumped.
645
- A re-resolve also silently folds unrelated dependency drift into the release
646
- commit the moment a Gemfile is out of sync with its lock. The only line a
647
- version bump changes is the path-gem pin, so the task edits exactly that (the
648
- `PATH` spec + the `CHECKSUMS` entry) in place — deterministic on any machine,
649
- no network, no installed gems, the same 2-line diff bundler produced. A
650
- lockfile that names no pin at all now **aborts** the release rather than
651
- reporting itself already current — and that check runs in a PREFLIGHT, before
652
- the task does anything destructive or irreversible: before the `force`
653
- cleanup that deletes the GitHub release and its tag, and before `version.rb`
654
- or any lockfile is written. So an abort leaves the release intact and the tree
655
- clean, rather than a deleted release or a half-bumped tree the clean-tree
656
- guard would then block on retry.
657
- pgbus's release task made the same call after the same failure.
658
-
659
- - **`Component::Action` renamed `ActionDefinition` — it shadowed a host kit
660
- component named `Action` under dev autoloading (#233).** The mixin sits in
661
- every reactive component's ancestry, so its bare `Action` constant satisfied
662
- Phlex::Kit's LazyLoader `const_get` check with the wrong class: the kit's
663
- real `Action` component never autoloaded and the call fell through to
664
- `NoMethodError` — intermittently (any unrelated load of the kit class masks
665
- it until the next reload) and only under lazy autoloading, so an
666
- eager-loaded test suite stays green. The rename matches its siblings
667
- (`ComputeDefinition`, `OnCompleteDefinition`, `CollectionDefinition`) and
668
- removes the constant entirely — **deliberately no alias**, since any
669
- `Action` constant in the ancestry re-creates the shadowing. Breaking only
670
- for code referencing the internal
671
- `Phlex::Reactive::Component::Action` Data class directly; the `action :name`
672
- DSL and `reactive_actions` registry are unchanged. A regression spec guards
673
- the whole component ancestry against `:Action` coming back.
674
-
675
- - **Multipart path corrupted/dropped fields with Rails-bracketed names (#231).**
676
- `#buildFormData` wrapped every collected field/file name verbatim as
677
- `params[<name>]`, so `blog_post[summary]` went out as the Rack-unparseable
678
- `params[blog_post[summary]]` — the scalar arrived as `{"]" => value}` (and a
679
- writer stringified that hash into the record, with a 200), and a `:file` under
680
- a bracketed name (`blog_post[image]`) never reached its schema key (silent
681
- drop). It only fired when a file input was populated — the JSON path expands
682
- the same names server-side — which is why flat-named forms never caught it.
683
- The client now bracket-expands the field **name** into wire segments
684
- (`params[blog_post][summary]`, `params[blog_post][image]`, and
685
- `params[photos][]` for a `multiple` picker named `photos[]`), mirroring the
686
- server's `bracket_path` exactly, so a multipart body and a JSON body coerce
687
- identically for the same fields. Flat names are byte-identical to before.
688
-
689
- - **Reply-rendered components now generate absolute URLs with the REQUESTING
690
- host, not the process default (#232).** Actor replies (`reply.replace`,
691
- `reply.streams(...)`, the implicit replace) render through the memoized
692
- off-request view context, whose `url_options` were process defaults — so any
693
- absolute URL helper (`image_tag` on an Active Storage attachment being the
694
- everyday case) rendered the wrong host on a multi-host app: a broken image
695
- after every save, healed by the next full page load. The action endpoint now
696
- threads the request's protocol/host/port into the reply render (the
697
- `ActiveStorage::SetCurrent` move) via a thread-local the memoized context
698
- merges per call — the context is never rebuilt, so the render memoization
699
- win is untouched (allocations byte-identical, throughput flat within noise).
700
- The defer **pull** endpoint gets the same treatment (it is an actor request).
701
- **Broadcasts are unchanged by design** — there is no request and subscribers
702
- can be on different hosts, so a broadcast fired *inside* an action explicitly
703
- clears the actor's url_options around its render; "URLs in broadcast-rendered
704
- components must be host-relative" is now documented in the Broadcasting guide.
705
-
706
- - **`rake bench` ran the removed `broadcast_replace_to` API and exited 1.**
707
- `benchmark/micro/broadcast.rb` migrated to the #185 spellings
708
- (`broadcast_to(*key, replace:)` / `broadcast_to(each:, replace:)`); the
709
- transport double is unchanged.
710
-
711
- - **`reactive_nested_remove(confirm:)` now interpolates `%{field}` on client-added
712
- rows (#222).** A row added in the browser via `reactive_nested_add` is a
713
- `cloneNode` of the `<template>`, and the clone path (`#renumberNestedRow` /
714
- `#seedNestedRow`) never rewrote the confirm attribute — so a per-row confirm
715
- froze to the template's value-less string on every added row (the exact rows
716
- the primitive exists for). The client now resolves `%{field}` placeholders in
717
- the confirm message from **that row's live field values** at click time (keyed
718
- by the same trailing-bracket inference `as: :json` uses), so
719
- `confirm: "Delete '%{name}'?"` on the template shows `Delete 'Widget'?` on the
720
- added row — reflecting a later edit too (resolved on remove, not on clone). An
721
- unresolved `%{key}` is left as its literal text (debuggable, never a silent
722
- blank); the placeholder works in the conditional Hash's `message:` as well.
723
- Server-rendered rows already interpolate server-side, so their finished strings
724
- are unaffected. **Also (superset of the issue's proposal 3):** `confirmResolver`
725
- now receives an optional second argument — a context object, always `{ el }`
726
- (the trigger), plus `{ row, fields }` on a `reactive_nested_remove` — so a
727
- themed-dialog override can build row-specific messages programmatically. The
728
- arg is additive: a one-parameter resolver (and `window.confirm`) is unchanged.
729
-
730
- - **A draft (unsaved-parent) token can now round-trip real server actions (#208).**
731
- `Component::Identity` already signed a gid-less `{c, state}` token for an
732
- unpersisted (or nil) record, but `from_identity` still `fetch`ed the absent
733
- `gid` — the FIRST action against a draft crashed with an uncaught `KeyError`
734
- (500). It now rebuilds through the record kwarg's **initialize default**
735
- (e.g. `def initialize(order: Order.new, …)`), with the declared
736
- `reactive_state` riding the token as before; a component whose initialize
737
- REQUIRES the record kwarg raises a guided `Phlex::Reactive::Error` naming the
738
- fix instead of a bare missing-keyword `ArgumentError`.
739
-
740
- - **`have_reactive_value` now reads the field's `.value` property, so it can verify a
741
- reducer-set disabled/computed field (#204).** The matcher (added in #201) delegated to
742
- Capybara's `have_field(with:)`, which matches the value **attribute** — but a
743
- `reactive_compute` reducer paints a computed output by setting its JS `.value`
744
- **property** (`el.value = …`), and for a **disabled / read-only** output the attribute
745
- never reflects that. So the matcher read `""` and failed on exactly the fields it was
746
- built for. It now re-resolves the field by id **or name** each poll and asserts the live
747
- `.value` property (via `evaluate_script`), covering enabled AND disabled/computed fields
748
- while keeping the morph-immunity. `have_reactive_text` (textContent) was unaffected.
749
-
750
- - **A freshly-rendered `reactive_compute` root now self-seeds its derived fields on connect (#199).**
751
- Before, a compute root computed nothing until the first user `input` — a fresh render
752
- (or a server validation-error re-render that replaced the body) left every derived
753
- output and cross-root `mirror:` node blank until you typed. Apps worked around it by
754
- dispatching a synthetic seed `input` on connect, but the compute root is a distinct
755
- Stimulus controller that may connect a frame later, so the seed raced its own wiring —
756
- the reported symptom being a PARTIAL apply (an early output painted; a later output
757
- and the mirror stayed blank). Now `connect()` runs ONE full `recompute()` after the
758
- controller is fully connected, so the whole single-pass write set (batch outputs →
759
- paint text sinks + cross-root mirrors → dispatch) runs synchronously and every
760
- declared output, text sink, and mirror paints from a single reducer result — no user
761
- interaction, no round trip. It re-seeds on `turbo:morph-element` (the show/filter/dirty
762
- precedent) and is idempotent (change-guarded writes make a re-seed a no-op, so an app
763
- still dispatching a synthetic input is harmless). The seed is gated on a new
764
- `data-reactive-compute-seed` marker emitted by `reactive_root(compute:)`; a non-compute
765
- root pays one attribute read and never seeds. The seed passes no event, so
766
- `meta.changed` is `null` — the correct "no field edited yet" semantics, so a convergent
767
- reducer's default branch computes the full settled set.
768
-
769
- - **Scoped form fields no longer silently drop their params — the #67 footgun is fixed (#184).**
770
- Under `reactive_scope`, `reactive_field(:date)` now emits the scoped wire name
771
- (`name="invoice[date]"`), so the POST arrives bracketed — and the endpoint unwraps
772
- exactly ONE scope level before schema matching, so a FLAT schema
773
- (`params: { date: :string }`) matches. Previously a scoped field either POSTed a bare
774
- name (misaligned with the `reactive_show`/`reactive_compute` resolvers, which already
775
- query `[name="scope[x]"]`) or forced a hand-written bracketed name + a hand-nested
776
- schema that Rails' bracket-expansion silently dropped. The client's `meta.changed`
777
- resolver (`#changedComputeField`) is now scope-aware too, so a scoped compute
778
- component's reducer still sees the bare declared name.
795
+ - **Client-side option filtering — `reactive_filter` (#163).** The other half
796
+ of #72's combobox: **preload the options, type to narrow — zero round
797
+ trips.** Spread `reactive_filter(input:, option:, group:, empty:)` onto the
798
+ root and the generic controller shows/hides each option on every keystroke by
799
+ a case-folded substring match against its `data-reactive-filter-text`
800
+ haystack (falling back to the option's own text) — no POST, no token, no
801
+ bespoke per-feature Stimulus controller. Optional `group:` collapses a header
802
+ whose every contained option is hidden; optional `empty:` reveals a
803
+ no-matches node at 0 visible. Selectors resolve within the root only (nested
804
+ reactive roots untouched), state seeds at connect and re-applies after a
805
+ morph, and blank selectors raise at render (a dead binding must fail loudly).
806
+ Composes with keyboard nav and per-row selection: a filtered-out option also
807
+ drops out of the Arrow-key path and loses its highlight, so Enter can never
808
+ pick an invisible row — selection itself stays a signed `on(:select)` action.
809
+ - **Standalone combobox keyboard nav — `reactive_listnav` (#163).** The same
810
+ Arrow/Enter/Escape wiring `on(…, listnav:)` appends, without the dispatch
811
+ descriptor — for the preload-and-filter input that fires **no** action (an
812
+ `on()` trigger would POST per keystroke). Spread
813
+ `reactive_listnav("[role=option]")` onto the input; Enter still picks by
814
+ clicking the highlighted option's own signed trigger.
815
+ - **Cross-root `reactive_show` targets — `reactive_show_targets` (#164).** A
816
+ field can now drive the visibility of declared elements **outside** its
817
+ reactive root — the nav tab, the panel in another tab pane, the sidebar note
818
+ a mode selector governs — the visibility parallel to the #159 cross-root
819
+ text mirror. The component that **owns** the field declares which outside
820
+ ids it governs, spread on the root:
821
+ `mix(reactive_root, reactive_show_targets(:mode, "#advanced-tab" =>
822
+ { equals: "advanced" }, "#basic-note" => { not: "advanced" }))`. Same
823
+ posture as `mirror:`: opt-in and declared, never implicit (a plain
824
+ `reactive_show` stays root-isolated, #15 untouched); targets are **single id
825
+ selectors only** — a class/compound selector raises at declare time AND is
826
+ warn-and-skipped by the client (two-sided default-deny); the predicate is
827
+ the same literal-only `reactive_show` vocabulary; the toggle is `hidden`
828
+ only. The field read stays **owned** — you can only drive outside visibility
829
+ from a field the declaring root owns. A target id not on the page is
830
+ silently skipped (an unrendered tab pane is normal); the cross-root pass
831
+ shares the owned-binding pass's field-read memo (one read per field per
832
+ sync). No map declared → one `getAttribute` and out. **One call per root**:
833
+ Phlex `mix` space-joins duplicate string data values, so a second call's
834
+ JSON would corrupt the attr (the client warns and ignores it) — several
835
+ fields go in one call via the hash form
836
+ (`reactive_show_targets(mode: { … }, kind: { … })`).
837
+ - **Value-conditional visibility — `reactive_show` (#161).** The `x-show` /
838
+ `data-show` / `wire:show` case — show/hide an element from a form field's
839
+ **current value** — no longer needs a hand-written `change`-listener Stimulus
840
+ controller. Spread `reactive_show(:mode, not: "off")` onto the element to
841
+ show/hide (also `equals:` and `in: [...]`; `equals: true` reads a checkbox's
842
+ checked state, a radio group reads the checked radio's value) and the generic
843
+ controller toggles the `hidden` attribute on every `input`/`change` —
844
+ client-only, zero round trip, no token. The predicate is a **declared literal
845
+ match**, never an expression (no eval surface); exactly one predicate is
846
+ enforced loudly at render; a missing field or malformed wire attr is
847
+ warn-and-skipped (client-side default-deny). Visibility seeds at connect and
848
+ re-syncs after a `turbo:morph-element`; a `reactive_compute` output write
849
+ dispatches a real `input` event, so derived values drive visibility too.
850
+ Ownership follows the nested-root rules (#15). Roots without a binding pay
851
+ one connect-time probe — no new listeners, byte-identical wire.
779
852
 
780
- - **`reactive_compute` output order no longer silently corrupts values (#183).**
781
- Each output write used to dispatch an `input` event **mid-loop**, re-entering the
782
- reducer synchronously from a **half-written DOM** — so a wrong `outputs:` order
783
- could commit wrong values (a real money-corruption class), settling only if the
784
- reducer was convergent in exactly the declared order. The client now runs the
785
- reducer **once** from one pre-write input snapshot, writes all changed outputs as
786
- a **batch**, paints text/mirror sinks from the settled values, then dispatches the
787
- `input` events — each self-marked so the root skips re-running its own reducer
788
- while other listeners (chained repaint, dirty tracking, `reactive_show`, sibling
789
- roots) still fire. Declared output order is no longer semantics. Covered by a JS
790
- order-independence test (declare outputs in the historically-wrong order — values
791
- stay correct) and a self-re-entry-suppression test.
853
+ - **Cross-root text mirrors + the `text` client op (#159).** A derived value can
854
+ now be painted into a text node **outside** the computing component's reactive
855
+ root — the read-only recap in another tab pane that previously forced a
856
+ bespoke JS listener — with the library's default-deny posture intact:
857
+ - `reactive_compute ..., mirror: { sum_total: "#sum_total" }` declares
858
+ allowlisted cross-root text mirrors. Each compute pass paints every declared
859
+ name into its document-wide **id** target(s) via `textContent`
860
+ (change-guarded, never `innerHTML`, never blanks a name the pass produced no
861
+ value for). The value comes from the reducer result, a just-written output's
862
+ field, or a declared input's identity value — so it works with no reducer at
863
+ all. Non-id selectors raise at declare time AND are warn-and-skipped by the
864
+ client interpreter (two-sided default-deny). No `mirror:` → the wire is
865
+ byte-identical to before.
866
+ - `js.text(to, value, global: false)` — a new op that sets `textContent`
867
+ (stringified; `nil` clears), available to `on_client`, `reply.js`, and
868
+ `broadcast_js_to`. Strictly less powerful than `set_attr`; pair with
869
+ `global: true` for the cross-root paint.
870
+ - `global: true` is now honored on the `reactive:js` stream path: a single op
871
+ can opt out of the reply's target-root scope to document-wide resolution
872
+ (previously it was silently ignored when a `target` was set).
792
873
 
793
874
  ### Changed
794
875
 
876
+ - **The client ships as two entries: the default, one file with everything,
877
+ and an opt-in core that imports feature modules on demand (#275). The
878
+ default is unchanged: nothing to do when upgrading.**
879
+ - **`phlex/reactive/reactive_controller` — the default — is still one file
880
+ with the whole client in it.** Same specifier, same imports (Stimulus and
881
+ the three override seams), nothing fetched on demand, every feature
882
+ connected inside `connect()` as before. Existing importmap pins, bundler
883
+ aliases and vendored copies keep working as they are. It contains no
884
+ `import()` at all. It is 24,675 B gzipped, 2,403 B more than the 22,272 B
885
+ it was before the split. The split first left it at 26,132 B; #305 builds
886
+ it with `__SPLIT__` false, so the runtime imports every feature statically
887
+ and calls it directly — no feature table, no loader, no `withFeature` —
888
+ and recovers 1,457 B of that. What remains is how the features are written
889
+ as modules (per-root state records where the controller had private
890
+ fields, exported wrappers, the `core` handle), not the boundary between
891
+ them. The budget test holds a 24,900 B ratchet; getting back to the
892
+ 22,700 B target (the pre-split size plus a loader's worth) is #310.
893
+ - **`phlex/reactive/core` — new, opt-in — is the controller without its
894
+ feature modules** (12,467 B gzipped). It imports a feature the first time
895
+ something on the page needs it, by a literal bare-specifier `import()`:
896
+ `phlex/reactive/features/persist` (3,210 B: `reactive_persist` drafts and
897
+ rich-text editors), `…/defer` (2,846 B: `reply.defer`, `reactive_lazy`,
898
+ `reactive_lazy(on:/cache:)`), `…/form` (1,015 B: dirty tracking,
899
+ `warn_unsaved`, the paste-trigger gate), `…/bindings` (5,360 B:
900
+ `reactive_show` and show targets, `reactive_on_complete`,
901
+ `reactive_filter`, `reactive_tags`, nested rows and their JSON mode, the
902
+ conditional `confirm:`), `…/compute` (2,084 B: `reactive_compute`,
903
+ `reactive_text`, the connect-time seed), `…/effects` (1,693 B:
904
+ `reactive_effects` and `dismiss_after`), `…/hints` (1,024 B: `optimistic:`
905
+ and `busy:`) and `…/devtools` (1,589 B: the latency simulator, the
906
+ zero-target warnings, the debug trace). The engine pins the core and every
907
+ feature (`preload: false`) and precompiles them. An app imports it INSTEAD
908
+ of `reactive_controller`, never both; loading both logs `the client was
909
+ loaded twice`. The README's "The split client" section is the opt-in
910
+ guide: what loads when, the trade-off, how to preload a feature, and the
911
+ one prefix alias a bundler needs.
912
+ - **The budget.** The epic's "core under 8 KB" became 10,240 B during the
913
+ work. The honest moves — every feature out, the test seams out of the
914
+ shipped build, the diagnostics into devtools, the conditional confirm and
915
+ its DNF fold into bindings, the compute seam import into compute — leave
916
+ the core at 12,383 B, 2,143 B over. The budget test holds the real size
917
+ (rounded up to 12,500 B) as the ratchet and prints the target; the PR for
918
+ the last phase lists what else could move and what each is worth. The
919
+ epic's criterion is amended to the shipped number.
920
+ - **Only with the split client:** the first root of a page load that needs
921
+ a feature waits for its import (later roots and Turbo visits do not).
922
+ While a draft-keeping root waits, nothing is drafted from the server's
923
+ blanks, what the user changes is drafted when the module arrives (also if
924
+ they leave the page first), a successful submit still forgets the draft,
925
+ `persist_state` / `persist_clear` wait for the restore, and an action
926
+ waits for the restore and posts the restored values — except when the
927
+ module is slower than the feature timeout, fails to load, or the root
928
+ leaves the page first. An `on:` event during a lazy shell's wait loads it
929
+ exactly once afterwards; a `reactive:defer` stream is kept and applied. A
930
+ stream that brings a page's first effect or dismissing flash — or swaps
931
+ in a root that needs a module — waits up to a second for the imports, and
932
+ the streams behind it keep their order; after that they render as they
933
+ are, and the effects module sweeps up the flashes it missed when it
934
+ arrives. A dirty-tracked form counts an edit made before its module
935
+ arrived, but `warn_unsaved` does not prompt for a navigation in that
936
+ window (preload `phlex/reactive/features/form` where that matters). A
937
+ form's bindings and a compute root show the server's rendering until
938
+ their module arrives, then seed from the fields as the user left them;
939
+ both hold the root's requests until then. A trigger with a hint has its
940
+ request wait for the hints module, then the hint applies and the request
941
+ goes out once; a failure after a late apply still reverts it. A
942
+ conditional `confirm:` asks once the bindings module can evaluate it.
943
+ `enableLatencySim` / `disableLatencySim` are exported by
944
+ `phlex/reactive/features/devtools` (the default client still exports them
945
+ itself). `reactive:error` gains `kind: "feature"`, whose detail carries
946
+ `feature`, `error` and a `phase` (one of `"load"`, `"timeout"`,
947
+ `"connect"`, `"detect"`); a feature that failed to load stays failed until
948
+ the page is reloaded.
949
+ - **`controller.featuresReady`** is a promise that resolves once the
950
+ features a root needs have connected. With the default client it is
951
+ always already resolved. It never rejects.
952
+ - A morph that adds a feature's marker to a connected, token-bearing root
953
+ connects the feature (and, on the split client, loads it first).
954
+ - The shipped builds no longer export the controller's `__…ForTest` seams
955
+ (they remain in the source the JS suite imports).
956
+ - `spec/javascript/bundle_budget.test.js` holds a ratchet on the default
957
+ file, the core, each feature and the split total (31,204 B).
958
+ - `rake bench:client` gains a `connect()` bench (2,000 roots), the one
959
+ place a feature module adds work to every root.
960
+ - Verified on Chromium (the browser suite, Puma and Falcon). Not run:
961
+ Firefox or WebKit; a real importmap-rails app with Propshaft digests on
962
+ the default entry (the docs site is one and its suite passes, but its
963
+ network requests were not inspected); the per-root `connect()` bench on
964
+ a quiet machine. A preloaded feature module (`preload: true`,
965
+ `modulepreload`) removes the network wait, not the window: it connects
966
+ in the task after `connect()`, and the first root's request gate applies
967
+ for that moment.
968
+
795
969
  - **`on_client` emits a binding record (#271).** `data-reactive-ops-param` now
796
970
  holds `{"on":…,"ops":[…], "window"?, "outside"?, "confirm"?, "confirmWhen"?}`
797
971
  instead of the bare `[[op, args]]` list, with every space written as
@@ -973,68 +1147,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
973
1147
  contradicted the clean-break rule. Each old name now raises a guided `NoMethodError`
974
1148
  naming the real method.
975
1149
 
976
- ### Fixed
977
-
978
- - **A pending-state text swap no longer destroys a composite trigger's icon (#181).**
979
- The former `disable_with:`/`loading:` text swap wrote `trigger.textContent`, which
980
- **flattens every child node** — a `<button><svg/> Save</button>` lost its icon the
981
- instant the request enqueued, and it never came back on restore. `busy:`'s swap now
982
- reads and restores `innerHTML`, so an icon-plus-label trigger round-trips intact.
983
- Covered by a new composite-trigger browser assertion and a JS child-preservation
984
- unit test.
985
-
986
- - **`exclude:`/`visible_to:` now actually reach pgbus — actor-echo suppression works over SSE (#187).**
987
- `broadcast_*_to`/`broadcast_*_to_each` passed `exclude:`/`visible_to:` as keyword
988
- arguments to `Turbo::StreamsChannel.broadcast_*_to`, but turbo-rails swallows
989
- unknown kwargs into its render locals — so on the pgbus transport the options were
990
- **silently dropped** and `exclude: reactive_connection_id` never suppressed the
991
- actor's own broadcast echo. They are now threaded through the thread-locals pgbus
992
- reads (`Thread.current[:pgbus_broadcast_exclude]`/`_visible_to`), capability-gated
993
- on `pgbus_streams?` so the Action Cable path is unchanged. Found by the new
994
- end-to-end pgbus transport suite (below) — the prior unit doubles only proved the
995
- option was *forwarded to the method*, never that it *reached pgbus*.
996
-
997
- ### Added
998
-
999
- - **Conditional confirm — warn only when the values look suspect (#179).**
1000
- `confirm:` now takes a Hash for soft-validation-before-submit, so the dialog fires
1001
- ONLY when the field values are wrong — instead of a hand-written submit handler that
1002
- inspects fields and calls `confirm()` itself. Two forms, both evaluated client-side
1003
- over the same collected fields `reactive_compute` reads:
1004
- - **Declarative** — `confirm: { when: { total: 0 }, message: "Total is 0 — continue?" }`.
1005
- `when:` reuses `reactive_show`'s conditions language verbatim: a scalar is equals,
1006
- a `Range` is a threshold (`qty: 100..`), an `Array` is membership. Zero JS. The
1007
- dialog fires when the condition MATCHES; a clean value submits silently.
1008
- - **Named predicate** — `confirm: { predicate: "end_before_start", message: "…" }` for
1009
- multi-field logic the single-field form can't express. Register a pure function at
1010
- boot (`setConfirmPredicate("end_before_start", ({ starts_at, ends_at }) => ends_at < starts_at)`),
1011
- the twin of `setComputeReducer`. An unregistered name warns and proceeds without a
1012
- dialog. Works on `on(...)` AND `on_client(...)`. The predicate is soft-validation UX,
1013
- **not authorization** — a user can bypass it and the action still hits the endpoint's
1014
- real authorize/default-deny; never let it stand in for a server-side check.
1015
-
1016
- - **`confirm:` on `on_client(...)` — themed confirmation for zero-round-trip client ops (#178).**
1017
- The client-op path gains the SAME overridable `confirmResolver` gate `on(:action, confirm:)`
1018
- has (#52/#55). A destructive-feeling client op (clear a draft, reset a form) gets the
1019
- app's themed dialog with one line and no round trip:
1020
- `button(**on_client(:click, js.text("#draft", ""), confirm: "Discard this draft?"))`.
1021
- The gate lives in the user-gesture path (`runOps`), never in the shared op applier — a
1022
- server-pushed `reactive:js` op stream must not prompt. `setConfirmResolver` now themes
1023
- both paths at once.
1024
-
1025
- - **CI transport verification matrix — the browser suite runs on Action Cable AND pgbus (#187).**
1026
- The system job is now `server × transport` (Puma/Falcon × cable/pgbus). The pgbus
1027
- cells add a plain `postgres:18` (pgbus vendors the PGMQ schema via its own
1028
- migrations — no extension image) and set `TRANSPORT=pgbus`, so the existing browser
1029
- suite proves the reactive round trip is transport-agnostic, and new `:pgbus`-tagged
1030
- specs prove real cross-tab broadcast delivery + actor-echo exclusion over live
1031
- Postgres SSE. `rake spec:system_matrix` runs the 2×2 locally (pgbus cells skip with
1032
- a note when Postgres isn't reachable); `rake pgbus:prepare_test_db` sets up the DB.
1033
- pgbus remains an optional, non-gemspec dependency — it is now a first-class *tested*
1034
- transport, not a required one.
1035
-
1036
- ### Changed
1037
-
1038
1150
  - **BREAKING: ONE pending-state vocabulary — `busy:` replaces `loading:` / `disable_with:` (#181).**
1039
1151
  A trigger's declarative pending affordance is now a single `on(…, busy:)` kwarg
1040
1152
  that shares `optimistic:`'s key vocabulary and normalizer — the only difference
@@ -1053,292 +1165,599 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1053
1165
  | `on(:save, loading: { disable: true, class: "opacity-50", text: "Saving…" })` | `on(:save, busy: { disable: true, add_class: "opacity-50", text: "Saving…" })` |
1054
1166
  | `on(:search, event: "input", listnav: "[role=option]")` | `mix(on(:search, event: "input"), reactive_listnav)` |
1055
1167
 
1056
- The wire attribute is renamed `data-reactive-loading-param` → `data-reactive-busy-param`;
1057
- the client keeps a **read shim** for the old attribute for one minor so an
1058
- in-flight page rendered by the previous gem across a deploy keeps its affordance.
1168
+ The wire attribute is renamed `data-reactive-loading-param` → `data-reactive-busy-param`;
1169
+ the client keeps a **read shim** for the old attribute for one minor so an
1170
+ in-flight page rendered by the previous gem across a deploy keeps its affordance.
1171
+
1172
+ - **BREAKING: `reactive_show` speaks ONE conditions language — `if:`/`if_any:`/`unless:` (#180).**
1173
+ The four accreted dialects (the positional-field predicate kwargs
1174
+ `equals:`/`not:`/`in:`/`gte:`/`gt:`/`lte:`/`lt:`, the `all:`/`any:` term
1175
+ arrays, and the `{ equals: … }` target predicate hashes) are **removed** and
1176
+ replaced by a single Ruby-native language: a **Hash is an AND**, an **Array is
1177
+ membership**, a **Range is a threshold**, `if_any:` is OR-of-AND (one level of
1178
+ disjunction — the distributive-law killer), and `unless:` negates. Each removed
1179
+ form raises a **guided `ArgumentError`** printing the exact rewrite. Migration:
1180
+
1181
+ | Before (removed) | After |
1182
+ |---|---|
1183
+ | `reactive_show(:mode, not: "off")` | `reactive_show(unless: { mode: "off" })` |
1184
+ | `reactive_show(:gift, equals: true)` | `reactive_show(if: { gift: true })` |
1185
+ | `reactive_show(:size, in: %w[l xl])` | `reactive_show(if: { size: %w[l xl] })` |
1186
+ | `reactive_show(:qty, gte: 10)` | `reactive_show(if: { qty: 10.. })` |
1187
+ | `reactive_show(:amt, gt: 5000)` | `reactive_show(unless: { amt: ..5000 })` |
1188
+ | `reactive_show(all: [{ field: :a, equals: "x" }, …])` | `reactive_show(if: { a: "x", … })` |
1189
+ | `reactive_show(any: [{ field: :a, equals: "x" }, …])` | `reactive_show(if_any: [{ a: "x" }, …])` |
1190
+ | `reactive_show_targets(:m, "#id" => { equals: "x" })` | `reactive_show_targets(:m, "#id" => "x")` |
1191
+
1192
+ New alongside it: **`reactive_values`** computes each binding's first-paint
1193
+ `hidden:` server-side (no per-section mirror method, no flash);
1194
+ **`reactive_scope :form`** lets bindings use bare field symbols
1195
+ (`[name="form[field]"]` on the client); **`disable: true`** disables a hidden
1196
+ section's own controls so a switched-away value never submits. The wire is now
1197
+ ONE DNF shape (`data-reactive-show='{"any":[[term,…],…]}'`); the client keeps
1198
+ legacy read arms for one minor (deploy overlap), removed in 0.11.
1199
+
1200
+ - **`Phlex::Reactive::ClientBindings` — a blessed client-only include (#180).** A
1201
+ view that only shows/hides, filters, or computes client-side (no server
1202
+ actions, no token) includes this instead of the full `Component`. It does NOT
1203
+ pull in `Streamable` (so it never clobbers an app's own `replace`/`to_stream_*`
1204
+ concern) or the token machinery — a token-less `reactive_root` with no `#id`
1205
+ requirement, invisible to `phlex_reactive:doctor`. The full `Component`
1206
+ includes it (one implementation), then layers Streamable + Identity on top.
1207
+
1208
+ - **Automatic token refresh — `reply.with` and `reply.streams` converge (#180).**
1209
+ The action endpoint now appends a `reactive:token` refresh stream automatically
1210
+ whenever a reply does not re-render the component's root, instead of prepending
1211
+ a full self-replace that could clobber a live input. Picking `.streams` vs
1212
+ `.with` to keep the signed token fresh is no longer a correctness decision — a
1213
+ companion-only reply refreshes the token either way.
1214
+
1215
+ - **BREAKING: `verify_authorized` is ON by default (#168).** A reactive action
1216
+ that completes **without any authorization call now raises**
1217
+ `Phlex::Reactive::AuthorizationNotVerified` — **rolling back the transaction**
1218
+ (fail-closed, stronger than Pundit's after-the-fact check). This is the
1219
+ presence-side complement to `authorization_errors`: a forgotten `authorize!`
1220
+ becomes a loud 500 your error tracker sees, not a silent hole. The guard
1221
+ detects a call to any `Phlex::Reactive.authorization_methods` name
1222
+ (default `%i[authorize! authorize allowed_to?]` — Pundit/CanCanCan/ActionPolicy)
1223
+ **or** `mark_authorized!`, made anywhere during the action (a helper the action
1224
+ calls counts too). **Three remedies** for each action:
1225
+ 1. call your authorization method (`authorize! @record, :update?`);
1226
+ 2. call `mark_authorized!` after a bespoke check the interceptor can't see;
1227
+ 3. declare `skip_verify_authorized` (whole component) or
1228
+ `skip_verify_authorized :action_name` (specific actions) for an
1229
+ intentionally public action.
1230
+ Turn it off globally with `Phlex::Reactive.verify_authorized = false` (the
1231
+ install-generator initializer documents the knob). The `action.phlex_reactive`
1232
+ instrumentation event gains a new `:unverified` outcome. A new **advisory**
1233
+ doctor check flags mutating actions with no detected authorization call
1234
+ (heuristic — a helper may authorize indirectly; never a hard fail).
1235
+
1236
+ ### Fixed
1237
+
1238
+ - **`reply.morph` no longer overwrites the field being typed in** (#338).
1239
+ Turbo's stream morph runs Idiomorph without `ignoreActiveValue`, so when a
1240
+ debounced save's render carried a different value — normalised by the
1241
+ server (`"Hello "` saved as `"Hello"`) or stale because the user kept typing
1242
+ during the round trip — the focused field took it, and the next keystrokes
1243
+ landed after it (`"Helloworld"`). Present since `reply.morph` (#28), so in
1244
+ 0.13.4. The client now holds the value of a focused `<input>`/`<textarea>`
1245
+ inside a reactive root through any morph (it cancels
1246
+ `turbo:before-morph-attribute` for `value` on that field, which Idiomorph
1247
+ honours for the attribute and the property) and writes the render's value as
1248
+ the field's new default itself, so dirty tracking still re-scans against the
1249
+ saved value. Unfocused fields, every other attribute (a root's token
1250
+ included), and morphs outside a reactive root are untouched. A field that
1251
+ must be rewritten while focused (a formatter) opts out with
1252
+ `data-reactive-morph-value`.
1253
+
1254
+ - **`reactive_scope` with a flat schema receives its fields from a real browser
1255
+ (#337).** The client's default JSON body keeps each field's bracketed name as
1256
+ one key (`"todo[title]"`), and the endpoint looked for the scope key (`todo`)
1257
+ before expanding those names, so it peeled nothing and the flat schema
1258
+ (`params: { title: :string }`) dropped every field. Only the multipart body,
1259
+ which Rails expands itself, worked; a request spec posting a nested hash
1260
+ passed. The endpoint now expands first and then peels, so both encodings
1261
+ give the action the same params, and `verbose_errors` no longer reports
1262
+ `todo[title]` as dropped. Bare params posted beside the scoped fields (a
1263
+ trigger's `on(:save, note: "x")`, an unscoped file input) now stay next to
1264
+ them on both encodings, where the multipart path used to drop them; an
1265
+ explicit `on(...)` param wins over a scoped field of the same name, as it
1266
+ does on the client. Only declared params still reach the
1267
+ action. Present since `reactive_scope` landed in 0.11.0 (#184), through
1268
+ 0.13.4.
1269
+
1270
+ - **A `transition:` on show/hide/toggle no longer ends when a child's
1271
+ animation or transition does.** `runTransition` had the settle bugs #296
1272
+ fixed in effects: a descendant's bubbling `animationend` consumed its
1273
+ `{ once: true }` listener and stripped the `during`/`to` classes early, and
1274
+ a CSS transition's own `transitionend` was never listened for. Only the
1275
+ element's own end event (or the fallback) settles it now, and settling
1276
+ removes both listeners. In a hidden tab the fallback already cleaned up (it
1277
+ never waited on a frame), but the late frame then re-added `to` for good;
1278
+ cleanup now cancels that frame and clears `from` too. Rapid show/hide on one
1279
+ element no longer lets a superseded run's late cleanup strip the new run's
1280
+ classes: a new run settles the live one first (its classes, timer,
1281
+ listeners and frame). And the fallback is no longer a fixed 350 ms that cut
1282
+ longer transitions short: it follows the element's computed
1283
+ transition/animation durations (times an animation's iteration count) plus
1284
+ delays (+50 ms), with 350 ms as the floor and a 5 s cap.
1285
+
1286
+ - **A custom-legs exit effect no longer waits for a background tab to become
1287
+ visible (#295).** The legs choreography awaited an animation frame before
1288
+ any timeout was armed, so in a hidden tab (no `requestAnimationFrame`) the
1289
+ removal, and every stream behind it, stalled. The frame wait now races the
1290
+ 1 s settle fallback.
1291
+
1292
+ - **An effect no longer ends when a child's animation or transition does
1293
+ (#296).** `animationend`/`transitionend` bubble, so a descendant finishing
1294
+ first cleared the container's effect class (and released an exit's removal)
1295
+ early; settling also left the other end listener attached. Only the
1296
+ element's own end event settles it now, and settling removes both listeners.
1297
+
1298
+ - **A JSON-mode nested row serializes what a real submit would carry
1299
+ (#299).** The row object behind `reactive_nested_list(as: :json)` and the
1300
+ per-row confirm `%{field}` interpolation now takes only the checked radio
1301
+ of a group (it took the last in DOM order), skips a nested reactive root's
1302
+ controls, and skips disabled controls (so `reactive_show(disable: true)`
1303
+ keeps a hidden field out of the JSON). A radio group with nothing checked,
1304
+ like a disabled control, now leaves its key out; a confirm `%{field}` for
1305
+ such a key stays literal.
1306
+
1307
+ - **A single `<select>` with no `selected` option no longer reads as dirty
1308
+ (#297).** The browser selects its first enabled option while that option's
1309
+ `defaultSelected` stays false; dirty tracking now compares a one-row single
1310
+ select against its reset state (the last `selected` option, else the first
1311
+ enabled one — an option inside a disabled `<optgroup>` is not enabled).
1312
+ - **Two dirty `warn_unsaved` roots prompt once per Turbo visit (#298).** The
1313
+ first dirty root to see a `turbo:before-visit` asks; the others skip that
1314
+ event, and any dirty root's decline still vetoes the visit.
1315
+
1316
+ - **A token an outside morph brings wins over the cached one (#301).** After a
1317
+ morph or update reply the controller kept the token it cached from that
1318
+ reply, so a later broadcast, page refresh or dormant morph-back that morphed
1319
+ the root in place with markup signed for another state was ignored, and the
1320
+ next action ran against the old state. The controller now drops its cached
1321
+ token whenever `data-reactive-token-value` changes (Stimulus'
1322
+ `tokenValueChanged`, which also fires on a reconnect after such a change).
1323
+
1324
+ - **A pushed deferred render stays pending until it arrives (#292).** The
1325
+ client settled a `via="stream"` defer on any turbo-stream to its target, so
1326
+ an unrelated update of that element before the job's broadcast released the
1327
+ guard (and the activity count) early. Only the job's removal of
1328
+ `reactive-defer-src-<target>` settles it now.
1329
+
1330
+ - **A morph inside a loading lazy shell no longer restarts its load (#294).**
1331
+ `turbo:morph-element` bubbles, so a morph of any descendant re-probed a
1332
+ `reactive_lazy` shell and aborted its in-flight fetch for a new one. Only a
1333
+ morph of the shell's root re-probes now.
1334
+
1335
+ - **A `hide:`/`show:` hint no longer flips a target that was already in that
1336
+ state (#300).** A `busy:` or `optimistic:` `hide:` aimed at an
1337
+ already-hidden element revealed it when the request settled (or failed), and
1338
+ a `show:` on a visible one hid it. hide/show now undo only what the hint
1339
+ changed, like the class ops.
1340
+
1341
+ - **Subclasses of a reactive component appear in the actions inventory
1342
+ (#302).** A subclass inherits `Phlex::Reactive::Component` instead of
1343
+ including it, so Streamable's `included` hook never registered it, and
1344
+ `phlex_reactive:actions`, `phlex_reactive:find`, the MCP tools and the doctor
1345
+ skipped it. Streamable now registers subclasses from an `inherited` hook. A
1346
+ subclass with a constant name is listed with its inherited and own actions
1347
+ and any inherited `skip_verify_authorized`; an anonymous subclass is still
1348
+ left out. Subclasses also have their memoized view context reset on a Rails
1349
+ code reload now.
1350
+
1351
+ - **The browser suite no longer wedges under Falcon when two requests
1352
+ overlap (#303).** Transactional system tests pin one connection that the
1353
+ test and the server share, guarded by a lock keyed on
1354
+ `ActiveSupport::IsolatedExecutionState` — a thread-keyed `ThreadMonitor`
1355
+ under the default `:thread` isolation. Falcon serves each request as a fiber
1356
+ on one thread, so the first page that made two action requests overlap (the
1357
+ two-roots hotkey fixture) let both fibers into the pinned connection's
1358
+ critical section: a `ThreadError` in `ConnectionPool#checkout`, after which
1359
+ every later request blocked and CI sat on the step until the 6-hour kill.
1360
+ The dummy app now sets `config.active_support.isolation_level = :fiber` when
1361
+ it serves under Falcon, as Rails documents for fiber-per-request servers; a
1362
+ spec reproduces the overlap without a browser. Apps that run Falcon need the
1363
+ same setting (the testing page says so).
1364
+
1365
+ - **A lazy shell connects the feature a morph asks for (#312).** A Turbo morph
1366
+ that kept a `reactive_lazy` shell connected (a `cache:` one, or a plain one)
1367
+ but turned it into a root needing persist, form, bindings or compute never
1368
+ connected that feature, or imported it on the opt-in core: the root's own
1369
+ morph listener was installed only on a root with an identity token, which a
1370
+ shell does not carry.
1371
+
1372
+ - **A refused fragment URL supersedes the in-flight lazy load (#293).** A morph
1373
+ that re-showed a lazy shell with a URL the client refuses left the earlier
1374
+ load running, and its late arrival painted over the refused shell.
1375
+
1376
+ - **A loaded `reactive_lazy` root morphed back into its shell loads again.**
1377
+ A plain `reactive_lazy` component only re-probed after a Turbo morph when its
1378
+ controller had connected on the shell. Once the real content had replaced it
1379
+ (a new element, a new controller), a page-refresh morph that re-showed the
1380
+ shell left it pending forever. The morph handler every token-bearing root
1381
+ already has now probes a fetch-on-connect shell too (found while wiring
1382
+ `cache:` into the same path, #277).
1383
+
1384
+ - **`reactive_compute` never saw a checkbox's checked state (#262).**
1385
+ `#recompute` resolved each declared name first-wins and read `.value`, which
1386
+ for a checkbox is a constant. Measured: a Rails `check_box` pair read `0`
1387
+ ticked or not (the hidden companion comes first), a lone `value="1"` box read
1388
+ `1` either way, and a box with no value attribute read `0` (`Number("on")` is
1389
+ NaN). A radio group read its FIRST radio's value, whichever was checked. The
1390
+ reducer ran on the toggle, from values that could not have changed.
1391
+
1392
+ A checkbox now contributes its checked state, coerced by the declared type:
1393
+ `1`/`0` as a `:number` (and in the array form), `"true"`/`"false"` as a
1394
+ `:string`, and `true`/`false` under the new **`:boolean`** type. It wins over
1395
+ its same-named hidden companion, as it already did for `reactive_show`. A radio
1396
+ group contributes its checked radio's value. The identity mirror and the
1397
+ `mirror:` fallback paint the same reading.
1398
+
1399
+ Outputs were wrong the same way: a result whose name resolved to a checkbox
1400
+ pair wrote `.value` on the hidden companion, changing what the UNCHECKED state
1401
+ submits and leaving the box alone. An output now sets a checkbox's `checked`
1402
+ from the result's truthiness (`""`, `"0"`, `"false"`, `0` and `false` untick)
1403
+ and checks the radio of a group that carries the result.
1404
+
1405
+ **If a reducer relied on the old reading**, it changes: a lone checkbox with a
1406
+ numeric `value` used to read that number regardless of state and now reads
1407
+ `1`/`0` — write the amount in the reducer (`gift ? 25 : 0`). Apps that worked
1408
+ around the defect by reading the box from `document` inside the reducer keep
1409
+ working and can drop the workaround.
1410
+
1411
+ Cost, same machine, happy-dom (engine-relative): the 30-input calculator
1412
+ bench goes from 16.3 to 18.4 µs/iter, allocations flat. That is one `el.type`
1413
+ read per declared name, which is what telling a checkbox from a text field
1414
+ takes.
1415
+
1416
+ - **The dropped-param hints said nothing for a schema with string keys.**
1417
+ `ParamSchema.compile` keeps keys as the author wrote them, so
1418
+ `params: { "date" => :string }` is valid, but the #16/#21 hints only looked
1419
+ up symbol keys. The param was still dropped and logged, just without the hint
1420
+ that says where the schema declares it.
1421
+
1422
+ - **A checkbox group collapsed to one boolean, and the chosen values never left
1423
+ the browser (#258).** `#collectFields` wrote `fields[name] = field.checked` for
1424
+ every checkbox, so several boxes sharing a `features[]` name overwrote each
1425
+ other and the action received the LAST box's checked state — `{}` under a
1426
+ `[:string]` schema, `"false"` under a flat `:string` one, silent either way,
1427
+ while a native submission of the same boxes sends
1428
+ `features[]=news&features[]=events`. A name ending in `[]` is now collected as
1429
+ an array of the chosen values: a ticked box contributes its `value`, an
1430
+ unticked one nothing, a `<select multiple>` its selected options. The suffix is
1431
+ the only trigger — a group says so rather than being inferred from two controls
1432
+ sharing a name.
1433
+
1434
+ **That makes the suffix a migration point.** ANY `[]`-named control now
1435
+ contributes to an array, including a single one and including a named rich
1436
+ editor or bare contenteditable, which the collector reads in a second pass: a
1437
+ lone `<input type="text" name="tags[]">` used to post `"abc"` and now posts
1438
+ `["abc"]`. An editor sharing its `[]` name with another control adds its
1439
+ value to the group instead of standing down behind it, so the entry count
1440
+ changes there too. Beside a radio it still stands down, because a radio keeps
1441
+ its single value with or without the suffix. A ready but empty editor
1442
+ contributes an empty string, the way an empty text field in the same group
1443
+ does. An unticked box still contributes nothing. A hidden input under the
1444
+ same name keeps contributing: nothing in the DOM tells a hidden that mirrors
1445
+ an editor from one that is a list JS maintains, and a value posted twice is
1446
+ visible where a swallowed one is not. Against a flat `params: { tags: :string
1447
+ }` the array coerces to the literal `"[\"abc\"]"` — silently, with a 200. A
1448
+ control whose name ends in `[]` has to be declared as an array type (`tags:
1449
+ [:string]`), or renamed without the suffix if it was never meant as a list.
1450
+
1451
+ Three shapes keep their meaning on purpose: a lone checkbox without `[]` stays
1452
+ the documented yes/no boolean, a radio group keeps its single checked value
1453
+ with or without the suffix, and a hidden input sharing a name with a checkbox
1454
+ is that box's companion. The companion is identified by the shared name, not
1455
+ by its value, because Rails renders three of them: `check_box` emits
1456
+ `value="0"`, `check_box(..., multiple: true)` the same under a `[]` name, and
1457
+ `collection_check_boxes` a blank one — or none at all with
1458
+ `unchecked_value: nil`. Reading the second shape by value collected
1459
+ `["0","0","0","3"]` for three boxes with the third ticked.
1460
+
1461
+ A form body can't carry an empty array, so a cleared group used to go missing
1462
+ whenever the client sent a form body (as soon as a file input holds a file),
1463
+ and the action got its keyword default instead of `[]`. Now the client names
1464
+ the cleared group in `empty_groups[]`, a field next to `token`, `act` and
1465
+ `params`, and the endpoint sets it to `[]`. A request without that field
1466
+ behaves as before, and a group that carries values keeps them.
1059
1467
 
1060
- - **BREAKING: `reactive_show` speaks ONE conditions language — `if:`/`if_any:`/`unless:` (#180).**
1061
- The four accreted dialects (the positional-field predicate kwargs
1062
- `equals:`/`not:`/`in:`/`gte:`/`gt:`/`lte:`/`lt:`, the `all:`/`any:` term
1063
- arrays, and the `{ equals: … }` target predicate hashes) are **removed** and
1064
- replaced by a single Ruby-native language: a **Hash is an AND**, an **Array is
1065
- membership**, a **Range is a threshold**, `if_any:` is OR-of-AND (one level of
1066
- disjunction — the distributive-law killer), and `unless:` negates. Each removed
1067
- form raises a **guided `ArgumentError`** printing the exact rewrite. Migration:
1468
+ It applies to params the action declares as an array, by plain name or with
1469
+ the component's `reactive_scope` in front, and only at the top level. A group
1470
+ declared one level down or inside nested attributes is ignored and keeps its
1471
+ keyword default; `verbose_errors` logs it. The endpoint never takes the key it
1472
+ writes from the request. It looks the name up in the declaration and writes
1473
+ the declared key, so a request can't create a param the action didn't ask for.
1068
1474
 
1069
- | Before (removed) | After |
1070
- |---|---|
1071
- | `reactive_show(:mode, not: "off")` | `reactive_show(unless: { mode: "off" })` |
1072
- | `reactive_show(:gift, equals: true)` | `reactive_show(if: { gift: true })` |
1073
- | `reactive_show(:size, in: %w[l xl])` | `reactive_show(if: { size: %w[l xl] })` |
1074
- | `reactive_show(:qty, gte: 10)` | `reactive_show(if: { qty: 10.. })` |
1075
- | `reactive_show(:amt, gt: 5000)` | `reactive_show(unless: { amt: ..5000 })` |
1076
- | `reactive_show(all: [{ field: :a, equals: "x" }, …])` | `reactive_show(if: { a: "x", … })` |
1077
- | `reactive_show(any: [{ field: :a, equals: "x" }, …])` | `reactive_show(if_any: [{ a: "x" }, …])` |
1078
- | `reactive_show_targets(:m, "#id" => { equals: "x" })` | `reactive_show_targets(:m, "#id" => "x")` |
1475
+ A blank entry (`params[name][]=""`) would have been the other way, but the
1476
+ schema reads `[""]` per element type, and for a `[:file]` param behind
1477
+ `has_many_attached` that is the difference between "not sent" and "remove the
1478
+ attachments".
1079
1479
 
1080
- New alongside it: **`reactive_values`** computes each binding's first-paint
1081
- `hidden:` server-side (no per-section mirror method, no flash);
1082
- **`reactive_scope :form`** lets bindings use bare field symbols
1083
- (`[name="form[field]"]` on the client); **`disable: true`** disables a hidden
1084
- section's own controls so a switched-away value never submits. The wire is now
1085
- ONE DNF shape (`data-reactive-show='{"any":[[term,…],…]}'`); the client keeps
1086
- legacy read arms for one minor (deploy overlap), removed in 0.11.
1480
+ `post_reactive_multipart` takes `empty_groups:` so a request spec can send a
1481
+ cleared group the way the client does. `ParamSchema#array_param(name)`
1482
+ returns the declared key for an array param, or `nil`.
1087
1483
 
1088
- - **`Phlex::Reactive::ClientBindings` — a blessed client-only include (#180).** A
1089
- view that only shows/hides, filters, or computes client-side (no server
1090
- actions, no token) includes this instead of the full `Component`. It does NOT
1091
- pull in `Streamable` (so it never clobbers an app's own `replace`/`to_stream_*`
1092
- concern) or the token machinery — a token-less `reactive_root` with no `#id`
1093
- requirement, invisible to `phlex_reactive:doctor`. The full `Component`
1094
- includes it (one implementation), then layers Streamable + Identity on top.
1484
+ `reactive_persist` drafts such a group as the list of ticked values and
1485
+ restores exactly those boxes; before, the draft held one boolean and the
1486
+ restore ticked every box of the group. Whether the server already rendered a
1487
+ box ticked — in which case it keeps its say and the draft yields — is decided
1488
+ once before the restore walks the controls, because the walk writes `checked`
1489
+ as it goes and asking from inside it would read the restore's own work: the
1490
+ first box it ticks would make every later box of the group look
1491
+ server-rendered, and a draft of two values would come back as one. A
1492
+ non-checkbox control sharing the group's name additionally threw inside the
1493
+ draft write, which is swallowed — the root then persisted nothing at all,
1494
+ silently. On restore, such a control keeps what the server rendered whenever
1495
+ the group has two or more contributors: the list records the values, not
1496
+ which control each one came from, so replaying it would paste `freeform,news`
1497
+ into a text field, an editor, or a contenteditable. A group of ONE
1498
+ contributor has no such ambiguity — its single entry can only have come from
1499
+ that control — so a plain field whose name merely ends in `[]`, the usual
1500
+ shape for a list JS maintains, keeps its draft exactly as it did before
1501
+ groups existed. A `<select multiple>` reads a list by matching option values,
1502
+ which is only sound when the list is its own: sharing a group with another
1503
+ contributor, it too keeps what the server rendered, since a text value that
1504
+ happens to equal an option would otherwise select it.
1095
1505
 
1096
- - **Automatic token refresh — `reply.with` and `reply.streams` converge (#180).**
1097
- The action endpoint now appends a `reactive:token` refresh stream automatically
1098
- whenever a reply does not re-render the component's root, instead of prepending
1099
- a full self-replace that could clobber a live input. Picking `.streams` vs
1100
- `.with` to keep the signed token fresh is no longer a correctness decision — a
1101
- companion-only reply refreshes the token either way.
1506
+ Drafts written before this release are not discarded, but their group key is
1507
+ no longer applied to the controls that read a list: it holds one boolean (or,
1508
+ in a mixed group, whichever control wrote last), and applying that kept
1509
+ causing damage for as long as the draft lived — by default seven days after
1510
+ the upgrade. The damage differed by control. A checkbox group came back fully
1511
+ ticked. A `<select multiple>` sharing the group's name lost its rendered
1512
+ selection instead, because under `restore: "always"` the select branch skips
1513
+ the "the server had a say" check and matches `Set{"true"}` against its
1514
+ options, where nothing matches. The next snapshot replaces the key with the
1515
+ list. Only that one key changed meaning, which is why `PERSIST_VERSION` stays
1516
+ where it is: bumping it would also throw away the drafted prose of every form
1517
+ that has no checkbox group at all.
1102
1518
 
1103
- - **BREAKING: `verify_authorized` is ON by default (#168).** A reactive action
1104
- that completes **without any authorization call now raises**
1105
- `Phlex::Reactive::AuthorizationNotVerified` — **rolling back the transaction**
1106
- (fail-closed, stronger than Pundit's after-the-fact check). This is the
1107
- presence-side complement to `authorization_errors`: a forgotten `authorize!`
1108
- becomes a loud 500 your error tracker sees, not a silent hole. The guard
1109
- detects a call to any `Phlex::Reactive.authorization_methods` name
1110
- (default `%i[authorize! authorize allowed_to?]` — Pundit/CanCanCan/ActionPolicy)
1111
- **or** `mark_authorized!`, made anywhere during the action (a helper the action
1112
- calls counts too). **Three remedies** for each action:
1113
- 1. call your authorization method (`authorize! @record, :update?`);
1114
- 2. call `mark_authorized!` after a bespoke check the interceptor can't see;
1115
- 3. declare `skip_verify_authorized` (whole component) or
1116
- `skip_verify_authorized :action_name` (specific actions) for an
1117
- intentionally public action.
1118
- Turn it off globally with `Phlex::Reactive.verify_authorized = false` (the
1119
- install-generator initializer documents the knob). The `action.phlex_reactive`
1120
- instrumentation event gains a new `:unverified` outcome. A new **advisory**
1121
- doctor check flags mutating actions with no detected authorization call
1122
- (heuristic — a helper may authorize indirectly; never a hard fail).
1519
+ - **`reply.pending` kept its settle handle under
1520
+ `enqueue_after_transaction_commit = true` (#254).** The handle was captured in
1521
+ `Phlex::Reactive::Settles#serialize`, which reads a thread-local that lives
1522
+ only for the duration of `reply.pending`'s enqueue block. Under Rails'
1523
+ `ActiveJob::Base.enqueue_after_transaction_commit = true` (the 7.2+
1524
+ recommended setting) `job.enqueue` is deferred to
1525
+ `ActiveRecord.after_all_transactions_commit`, and the endpoint runs every
1526
+ action inside a transaction — so `serialize` always ran AFTER the block had
1527
+ exited. The job serialized with no `phlex_reactive_settle` key,
1528
+ `reactive_settle` was a no-op, and the row sat shimmering until someone
1529
+ reloaded. The handle is now captured when the job INSTANCE is created
1530
+ (`perform_later` → `job_or_instantiate` → `new`), which is synchronous inside
1531
+ the block whether or not the enqueue itself is deferred; `serialize` prefers
1532
+ the captured handle and still falls back to the thread-local. Both the
1533
+ `job:`/`args:` sugar (narrowing per record, so failures stay attributable) and
1534
+ the block form are covered, and a retry re-enqueue keeps its handle. Jobs
1535
+ enqueued outside any `reply.pending` still carry nothing.
1123
1536
 
1124
- ### Added
1537
+ - **`rake release` bumps the pin in every tracked lockfile (#247, #253).** Since
1538
+ #246 the gem root's `Gemfile.lock` is committed alongside `docs/Gemfile.lock`,
1539
+ and both pin `phlex-reactive` by local path — so a version bump that left them
1540
+ alone shipped a stale lockfile, and the Release workflow's frozen
1541
+ `bundle install` refused it (v0.13.0's first attempt).
1125
1542
 
1126
- - **Compound & numeric `reactive_show` predicates — `all:`/`any:` and
1127
- `gte:`/`gt:`/`lte:`/`lt:` (#176).** Value-conditional visibility now spans
1128
- **more than one field** and **numeric thresholds**, staying inside the
1129
- eval-free "declared literal predicate" contract. `all:` / `any:` fold a list of
1130
- per-field terms (`{ field:, equals:/not:/in:/gte:/… }`) with one fixed
1131
- connective — AND vs OR over the same literal vocabulary, no expression surface;
1132
- one flat binding replaces wrapper-div nesting and is the only way to express OR.
1133
- `gte:`/`gt:`/`lte:`/`lt:` compare `Number(value)` against a literal number baked
1134
- into the binding (the RHS must be a real `Numeric` — a typo fails at render); a
1135
- non-numeric field value is `NaN` → hidden, the safe reveal-on-threshold default.
1136
- Numeric predicates work standalone, as a compound term, and inside a
1137
- `reactive_show_targets` map. Malformed terms fold **false** (fail-closed:
1138
- default-deny). The single-field `reactive_show(:field, equals:)` form is
1139
- unchanged; the additions are backwards compatible.
1543
+ The task now bumps that pin **with a text edit**, not `bundle lock`. #247's
1544
+ first cut used `bundle lock --local`, which is a full re-resolve — and a
1545
+ re-resolve trips over constraints that have nothing to do with this gem:
1546
+ `docs/Gemfile.lock` declares Linux platforms for the Kamal deploy, and
1547
+ resolving `thruster` for those against a Mac's installed gems fails ("Could
1548
+ not find gems matching 'thruster' valid for all resolution platforms"), which
1549
+ aborted v0.13.1's first attempt mid-release with `version.rb` already bumped.
1550
+ A re-resolve also silently folds unrelated dependency drift into the release
1551
+ commit the moment a Gemfile is out of sync with its lock. The only line a
1552
+ version bump changes is the path-gem pin, so the task edits exactly that (the
1553
+ `PATH` spec + the `CHECKSUMS` entry) in place — deterministic on any machine,
1554
+ no network, no installed gems, the same 2-line diff bundler produced. A
1555
+ lockfile that names no pin at all now **aborts** the release rather than
1556
+ reporting itself already current — and that check runs in a PREFLIGHT, before
1557
+ the task does anything destructive or irreversible: before the `force`
1558
+ cleanup that deletes the GitHub release and its tag, and before `version.rb`
1559
+ or any lockfile is written. So an abort leaves the release intact and the tree
1560
+ clean, rather than a deleted release or a half-bumped tree the clean-tree
1561
+ guard would then block on retry.
1562
+ pgbus's release task made the same call after the same failure.
1140
1563
 
1141
- - **Installable Claude debugging skill + `rails g phlex:reactive:claude` (#168).**
1142
- The gem ships a `phlex-reactive-debugging` skill (the doctor → inventory → find
1143
- → browser `report()` → MCP workflow + a failure table) under `lib/`, and the
1144
- new generator copies it into a host app's `.claude/skills/` and writes the MCP
1145
- server entry to `.mcp.json` — only when absent (it never rewrites an existing
1146
- `.mcp.json`; it prints the snippet instead). A new **Debugging & tooling** docs
1147
- page ties the four surfaces together; the security page documents
1148
- `verify_authorized`; the instrumentation table gains the `:unverified` outcome;
1149
- the README gains a Debugging & tooling section + the config rows.
1564
+ - **`Component::Action` renamed `ActionDefinition` — it shadowed a host kit
1565
+ component named `Action` under dev autoloading (#233).** The mixin sits in
1566
+ every reactive component's ancestry, so its bare `Action` constant satisfied
1567
+ Phlex::Kit's LazyLoader `const_get` check with the wrong class: the kit's
1568
+ real `Action` component never autoloaded and the call fell through to
1569
+ `NoMethodError` — intermittently (any unrelated load of the kit class masks
1570
+ it until the next reload) and only under lazy autoloading, so an
1571
+ eager-loaded test suite stays green. The rename matches its siblings
1572
+ (`ComputeDefinition`, `OnCompleteDefinition`, `CollectionDefinition`) and
1573
+ removes the constant entirely — **deliberately no alias**, since any
1574
+ `Action` constant in the ancestry re-creates the shadowing. Breaking only
1575
+ for code referencing the internal
1576
+ `Phlex::Reactive::Component::Action` Data class directly; the `action :name`
1577
+ DSL and `reactive_actions` registry are unchanged. A regression spec guards
1578
+ the whole component ancestry against `:Action` coming back.
1150
1579
 
1151
- - **On-demand client inspector — `phlex/reactive/inspect` (#168).** A standalone
1152
- JS module (the `confirm.js`/`compute.js` precedent — **zero hot-path cost**, no
1153
- edit to `reactive_controller.js`, loaded only when imported) that scans the live
1154
- DOM and maps every reactive root + bound trigger back to the server
1155
- `Component#action` names. From the browser console:
1156
- `(await import("phlex/reactive/inspect")).report()` prints a `console.table` of
1157
- every reactive root — its `id`, decoded token payload (component class, gid,
1158
- state keys, token version — try/catch base64+JSON decode, degrading to
1159
- `{ opaque: true }` on a Marshal-serialized payload), status attrs, triggers
1160
- (action + event + params + debounce/throttle/confirm), client-only ops,
1161
- computes, the `name`d fields the dispatch would collect, and the
1162
- show/filter/text binding families. Triggers are scoped to the **nearest** root,
1163
- so nested roots aren't double-attributed. The server↔client mapping is
1164
- by-name: `scan()`'s `component` + trigger `action` strings are exactly the
1165
- identifiers `phlex_reactive:actions` and the MCP tools list. Pinned by the
1166
- engine (not preloaded); pure read, never mutates the page.
1580
+ - **Multipart path corrupted/dropped fields with Rails-bracketed names (#231).**
1581
+ `#buildFormData` wrapped every collected field/file name verbatim as
1582
+ `params[<name>]`, so `blog_post[summary]` went out as the Rack-unparseable
1583
+ `params[blog_post[summary]]` — the scalar arrived as `{"]" => value}` (and a
1584
+ writer stringified that hash into the record, with a 200), and a `:file` under
1585
+ a bracketed name (`blog_post[image]`) never reached its schema key (silent
1586
+ drop). It only fired when a file input was populated — the JSON path expands
1587
+ the same names server-side — which is why flat-named forms never caught it.
1588
+ The client now bracket-expands the field **name** into wire segments
1589
+ (`params[blog_post][summary]`, `params[blog_post][image]`, and
1590
+ `params[photos][]` for a `multiple` picker named `photos[]`), mirroring the
1591
+ server's `bracket_path` exactly, so a multipart body and a JSON body coerce
1592
+ identically for the same fields. Flat names are byte-identical to before.
1167
1593
 
1168
- - **Read-only diagnostic MCP server (#168).** `bin/rails phlex_reactive:mcp`
1169
- starts a stdio [MCP](https://modelcontextprotocol.io) server exposing five
1170
- read-only tools — `phlex_reactive_doctor`, `phlex_reactive_components`,
1171
- `phlex_reactive_actions` (optional `component:` filter), `phlex_reactive_find`
1172
- (fuzzy search + Prism method source), `phlex_reactive_config` (redacted) — so
1173
- Claude Code inside a host app can introspect the live reactive registry when
1174
- debugging. The `mcp` gem is **optional and lazy**: it is NOT a gemspec runtime
1175
- dependency; `Phlex::Reactive::MCP.load!` requires it on demand with a helpful
1176
- message when missing (the pgbus pattern), and the gem-dependent tool tree stays
1177
- out of the Zeitwerk autoloader — a host app without `mcp` boots and eager-loads
1178
- unaffected. Every tool is read-only and non-destructive (no arbitrary-query or
1179
- mutation tool) and reports names/paths/schemas only — never a token, secret, or
1180
- runtime state; `phlex_reactive_config` never emits the verifier or
1181
- `secret_key_base`. Consumer `.mcp.json`:
1182
- `{ "mcpServers": { "phlex-reactive": { "command": "bin/rails", "args": ["phlex_reactive:mcp"] } } }`.
1183
- Known constraint: stdio MCP needs a clean stdout — an initializer that `puts`
1184
- breaks the transport (same caveat as pgbus).
1594
+ - **Reply-rendered components now generate absolute URLs with the REQUESTING
1595
+ host, not the process default (#232).** Actor replies (`reply.replace`,
1596
+ `reply.streams(...)`, the implicit replace) render through the memoized
1597
+ off-request view context, whose `url_options` were process defaults — so any
1598
+ absolute URL helper (`image_tag` on an Active Storage attachment being the
1599
+ everyday case) rendered the wrong host on a multi-host app: a broken image
1600
+ after every save, healed by the next full page load. The action endpoint now
1601
+ threads the request's protocol/host/port into the reply render (the
1602
+ `ActiveStorage::SetCurrent` move) via a thread-local the memoized context
1603
+ merges per call — the context is never rebuilt, so the render memoization
1604
+ win is untouched (allocations byte-identical, throughput flat within noise).
1605
+ The defer **pull** endpoint gets the same treatment (it is an actor request).
1606
+ **Broadcasts are unchanged by design** — there is no request and subscribers
1607
+ can be on different hosts, so a broadcast fired *inside* an action explicitly
1608
+ clears the actor's url_options around its render; "URLs in broadcast-rendered
1609
+ components must be host-relative" is now documented in the Broadcasting guide.
1185
1610
 
1186
- - **verify_authorized runtime guard (#168).** New
1187
- `Phlex::Reactive::Authorization` (fiber-local tracking window, method
1188
- interception, the enforcement decision), `mark_authorized!` instance helper,
1189
- the `skip_verify_authorized` DSL (registry #6, inherits like the other five),
1190
- and `Phlex::Reactive.verify_authorized` / `authorization_methods` config
1191
- (`defined?`-guarded so an explicit override sticks). See the breaking note
1192
- above for the behavior and remedies.
1611
+ - **`rake bench` ran the removed `broadcast_replace_to` API and exited 1.**
1612
+ `benchmark/micro/broadcast.rb` migrated to the #185 spellings
1613
+ (`broadcast_to(*key, replace:)` / `broadcast_to(each:, replace:)`); the
1614
+ transport double is unchanged.
1193
1615
 
1194
- - **Action inventory — `Phlex::Reactive::Inspector` + rake tasks (#168).** A new
1195
- read-only introspection layer that answers "what reactive actions exist in this
1196
- app, where are they defined, and is each authorized?" without grepping.
1197
- `Phlex::Reactive::Inspector.components` discovers every constant-backed reactive
1198
- component from the loaded `Streamable` registry; `.find(query)` fuzzy-matches
1199
- one (exact > prefix > substring > subsequence, on both the demodulized and the
1200
- full name). Each action reports its declared param schema, `file:line`, the full
1201
- `def … end` source (extracted with **Prism**, degrading to `nil` on an
1202
- unreadable/unparseable file — never raising), and a **heuristic** authorization
1203
- status (a Prism scan for a configured authorization method or
1204
- `mark_authorized!` in the body — advisory only, since a helper may authorize
1205
- indirectly). Two shipped rake tasks surface it: `bin/rails
1206
- phlex_reactive:actions` (plain-text table, `FORMAT=json` for tooling) and
1207
- `bin/rails "phlex_reactive:find[query]"` (ranked matches; top match in detail
1208
- with each action's method source). Output is names/paths/schemas only — never
1209
- tokens, secrets, or runtime state (the instrumentation privacy contract
1210
- extended to tooling). `Doctor` now delegates its `constant_backed_component?`
1211
- filter to the Inspector so the endpoint-rebuild predicate lives in one place.
1616
+ - **`reactive_nested_remove(confirm:)` now interpolates `%{field}` on client-added
1617
+ rows (#222).** A row added in the browser via `reactive_nested_add` is a
1618
+ `cloneNode` of the `<template>`, and the clone path (`#renumberNestedRow` /
1619
+ `#seedNestedRow`) never rewrote the confirm attribute — so a per-row confirm
1620
+ froze to the template's value-less string on every added row (the exact rows
1621
+ the primitive exists for). The client now resolves `%{field}` placeholders in
1622
+ the confirm message from **that row's live field values** at click time (keyed
1623
+ by the same trailing-bracket inference `as: :json` uses), so
1624
+ `confirm: "Delete '%{name}'?"` on the template shows `Delete 'Widget'?` on the
1625
+ added row — reflecting a later edit too (resolved on remove, not on clone). An
1626
+ unresolved `%{key}` is left as its literal text (debuggable, never a silent
1627
+ blank); the placeholder works in the conditional Hash's `message:` as well.
1628
+ Server-rendered rows already interpolate server-side, so their finished strings
1629
+ are unaffected. **Also (superset of the issue's proposal 3):** `confirmResolver`
1630
+ now receives an optional second argument — a context object, always `{ el }`
1631
+ (the trigger), plus `{ row, fields }` on a `reactive_nested_remove` — so a
1632
+ themed-dialog override can build row-specific messages programmatically. The
1633
+ arg is additive: a one-parameter resolver (and `window.confirm`) is unchanged.
1212
1634
 
1213
- - **Deferred reply segments — `reply.defer` (#165).** An expensive part of a
1214
- reply (a cross-aggregate rollup, a report) no longer stalls the actor's
1215
- interaction: `reply.streams(cheap).defer(SessionTotals.new(workout:))`
1216
- returns the cheap streams immediately and streams the real render to the
1217
- SAME actor when it finishes. Keep-content default (the stale value stays
1218
- visible, marked `data-reactive-defer-pending` + `aria-busy` for CSS
1219
- shimmer); `placeholder: true` / a component swaps a skeleton in;
1220
- `morph: true` morphs the arrival (the mode rides INSIDE the signed token).
1221
- Transactional (the directive rides the post-commit reply — a rollback or a
1222
- denied action leaks nothing), actor-scoped (peers keep `broadcast_*_to`),
1223
- superseding (a newer action for the same target aborts the in-flight
1224
- deferred render — no stale paint), and interactive on arrival (fresh action
1225
- token). Delivery is transport-adaptive (`Phlex::Reactive.defer_transport`,
1226
- default `:auto`): a parallel fetch to the new `POST /reactive/defer`
1227
- endpoint everywhere (purpose-scoped, short-TTL defer token —
1228
- `defer_token_ttl`, default 120s; `reply.defer` tokens are **actor-bound**
1229
- to the requesting session so a leaked one can't be redeemed elsewhere,
1230
- `reactive_lazy` shell tokens are unbound by necessity — they render
1231
- before a session exists — with the TTL + `authorize!` as their bound;
1232
- never interchangeable with action tokens),
1233
- or a **durable pgbus one-shot stream + `DeferredRenderJob`** when pgbus's
1234
- reactive Streams and ActiveJob are present (`defer_job_queue` config; the
1235
- durable since-id replay closes the broadcast-before-subscribe race, and the
1236
- broadcast tears down its own subscription). The push lane's one-shot queue is
1237
- reclaimed by pgbus's age-based orphan-stream sweep (**pgbus ≥ 0.9.10**; run
1238
- the Dispatcher with `streams_orphan_threshold` set); we never eager-drop it
1239
- (that would reopen the delivery race). The one-shot key is sized to the live
1240
- pgbus `queue_prefix` budget, so a non-default prefix can't overflow it; the
1241
- render job broadcasts a cleanup on ANY failure so the actor's pending state
1242
- always resolves. Every capability gap degrades to the fetch lane — the
1243
- Action-Cable-or-pgbus invariant holds. **Profile first:** an app-side N+1
1244
- looks exactly like framework lag; defer is for segments that are genuinely
1245
- expensive after the synchronous path is cheap.
1635
+ - **A draft (unsaved-parent) token can now round-trip real server actions (#208).**
1636
+ `Component::Identity` already signed a gid-less `{c, state}` token for an
1637
+ unpersisted (or nil) record, but `from_identity` still `fetch`ed the absent
1638
+ `gid` — the FIRST action against a draft crashed with an uncaught `KeyError`
1639
+ (500). It now rebuilds through the record kwarg's **initialize default**
1640
+ (e.g. `def initialize(order: Order.new, …)`), with the declared
1641
+ `reactive_state` riding the token as before; a component whose initialize
1642
+ REQUIRES the record kwarg raises a guided `Phlex::Reactive::Error` naming the
1643
+ fix instead of a bare missing-keyword `ArgumentError`.
1246
1644
 
1247
- - **Lazy initial mount — `reactive_lazy` (#165).** The same machinery for the
1248
- FIRST render (Livewire `#[Lazy]`): the page ships the component's
1249
- placeholder shell (`deferred_placeholder`, or a built-in pending shell) with
1250
- the defer token on the root; the client fetches the real content on connect
1251
- AND after a Turbo page-refresh morph (so a lazy component survives a
1252
- `turbo:reload`). `reactive_lazy tag: :tr` (etc.) ships a shell element that
1253
- matches a `<tr>`/`<li>` root instead of an invalid `<div>`.
1254
- Reactive-machinery renders (an action's self-replace, broadcasts, the defer
1255
- endpoint/job) stay REAL, so actions never pay two round trips.
1645
+ - **`have_reactive_value` now reads the field's `.value` property, so it can verify a
1646
+ reducer-set disabled/computed field (#204).** The matcher (added in #201) delegated to
1647
+ Capybara's `have_field(with:)`, which matches the value **attribute** — but a
1648
+ `reactive_compute` reducer paints a computed output by setting its JS `.value`
1649
+ **property** (`el.value = …`), and for a **disabled / read-only** output the attribute
1650
+ never reflects that. So the matcher read `""` and failed on exactly the fields it was
1651
+ built for. It now re-resolves the field by id **or name** each poll and asserts the live
1652
+ `.value` property (via `evaluate_script`), covering enabled AND disabled/computed fields
1653
+ while keeping the morph-immunity. `have_reactive_text` (textContent) was unaffected.
1256
1654
 
1257
- - **pgbus capability gates.** `Phlex::Reactive.pgbus?` and `.pgbus_streams?`
1258
- (the documented broadcast-accepts-`:exclude` probe, now actually
1259
- implemented) plus `.defer_push_capable?` for the defer push lane.
1655
+ - **A freshly-rendered `reactive_compute` root now self-seeds its derived fields on connect (#199).**
1656
+ Before, a compute root computed nothing until the first user `input` — a fresh render
1657
+ (or a server validation-error re-render that replaced the body) left every derived
1658
+ output and cross-root `mirror:` node blank until you typed. Apps worked around it by
1659
+ dispatching a synthetic seed `input` on connect, but the compute root is a distinct
1660
+ Stimulus controller that may connect a frame later, so the seed raced its own wiring —
1661
+ the reported symptom being a PARTIAL apply (an early output painted; a later output
1662
+ and the mirror stayed blank). Now `connect()` runs ONE full `recompute()` after the
1663
+ controller is fully connected, so the whole single-pass write set (batch outputs →
1664
+ paint text sinks + cross-root mirrors → dispatch) runs synchronously and every
1665
+ declared output, text sink, and mirror paints from a single reducer result — no user
1666
+ interaction, no round trip. It re-seeds on `turbo:morph-element` (the show/filter/dirty
1667
+ precedent) and is idempotent (change-guarded writes make a re-seed a no-op, so an app
1668
+ still dispatching a synthetic input is harmless). The seed is gated on a new
1669
+ `data-reactive-compute-seed` marker emitted by `reactive_root(compute:)`; a non-compute
1670
+ root pays one attribute read and never seeds. The seed passes no event, so
1671
+ `meta.changed` is `null` — the correct "no field edited yet" semantics, so a convergent
1672
+ reducer's default branch computes the full settled set.
1260
1673
 
1261
- - **Client-side option filtering — `reactive_filter` (#163).** The other half
1262
- of #72's combobox: **preload the options, type to narrow — zero round
1263
- trips.** Spread `reactive_filter(input:, option:, group:, empty:)` onto the
1264
- root and the generic controller shows/hides each option on every keystroke by
1265
- a case-folded substring match against its `data-reactive-filter-text`
1266
- haystack (falling back to the option's own text) — no POST, no token, no
1267
- bespoke per-feature Stimulus controller. Optional `group:` collapses a header
1268
- whose every contained option is hidden; optional `empty:` reveals a
1269
- no-matches node at 0 visible. Selectors resolve within the root only (nested
1270
- reactive roots untouched), state seeds at connect and re-applies after a
1271
- morph, and blank selectors raise at render (a dead binding must fail loudly).
1272
- Composes with keyboard nav and per-row selection: a filtered-out option also
1273
- drops out of the Arrow-key path and loses its highlight, so Enter can never
1274
- pick an invisible row — selection itself stays a signed `on(:select)` action.
1275
- - **Standalone combobox keyboard nav — `reactive_listnav` (#163).** The same
1276
- Arrow/Enter/Escape wiring `on(…, listnav:)` appends, without the dispatch
1277
- descriptor — for the preload-and-filter input that fires **no** action (an
1278
- `on()` trigger would POST per keystroke). Spread
1279
- `reactive_listnav("[role=option]")` onto the input; Enter still picks by
1280
- clicking the highlighted option's own signed trigger.
1281
- - **Cross-root `reactive_show` targets — `reactive_show_targets` (#164).** A
1282
- field can now drive the visibility of declared elements **outside** its
1283
- reactive root — the nav tab, the panel in another tab pane, the sidebar note
1284
- a mode selector governs — the visibility parallel to the #159 cross-root
1285
- text mirror. The component that **owns** the field declares which outside
1286
- ids it governs, spread on the root:
1287
- `mix(reactive_root, reactive_show_targets(:mode, "#advanced-tab" =>
1288
- { equals: "advanced" }, "#basic-note" => { not: "advanced" }))`. Same
1289
- posture as `mirror:`: opt-in and declared, never implicit (a plain
1290
- `reactive_show` stays root-isolated, #15 untouched); targets are **single id
1291
- selectors only** — a class/compound selector raises at declare time AND is
1292
- warn-and-skipped by the client (two-sided default-deny); the predicate is
1293
- the same literal-only `reactive_show` vocabulary; the toggle is `hidden`
1294
- only. The field read stays **owned** — you can only drive outside visibility
1295
- from a field the declaring root owns. A target id not on the page is
1296
- silently skipped (an unrendered tab pane is normal); the cross-root pass
1297
- shares the owned-binding pass's field-read memo (one read per field per
1298
- sync). No map declared → one `getAttribute` and out. **One call per root**:
1299
- Phlex `mix` space-joins duplicate string data values, so a second call's
1300
- JSON would corrupt the attr (the client warns and ignores it) — several
1301
- fields go in one call via the hash form
1302
- (`reactive_show_targets(mode: { … }, kind: { … })`).
1303
- - **Value-conditional visibility — `reactive_show` (#161).** The `x-show` /
1304
- `data-show` / `wire:show` case — show/hide an element from a form field's
1305
- **current value** — no longer needs a hand-written `change`-listener Stimulus
1306
- controller. Spread `reactive_show(:mode, not: "off")` onto the element to
1307
- show/hide (also `equals:` and `in: [...]`; `equals: true` reads a checkbox's
1308
- checked state, a radio group reads the checked radio's value) and the generic
1309
- controller toggles the `hidden` attribute on every `input`/`change` —
1310
- client-only, zero round trip, no token. The predicate is a **declared literal
1311
- match**, never an expression (no eval surface); exactly one predicate is
1312
- enforced loudly at render; a missing field or malformed wire attr is
1313
- warn-and-skipped (client-side default-deny). Visibility seeds at connect and
1314
- re-syncs after a `turbo:morph-element`; a `reactive_compute` output write
1315
- dispatches a real `input` event, so derived values drive visibility too.
1316
- Ownership follows the nested-root rules (#15). Roots without a binding pay
1317
- one connect-time probe — no new listeners, byte-identical wire.
1674
+ - **Scoped form fields no longer silently drop their params — the #67 footgun is fixed (#184).**
1675
+ Under `reactive_scope`, `reactive_field(:date)` now emits the scoped wire name
1676
+ (`name="invoice[date]"`), so the POST arrives bracketed — and the endpoint unwraps
1677
+ exactly ONE scope level before schema matching, so a FLAT schema
1678
+ (`params: { date: :string }`) matches. Previously a scoped field either POSTed a bare
1679
+ name (misaligned with the `reactive_show`/`reactive_compute` resolvers, which already
1680
+ query `[name="scope[x]"]`) or forced a hand-written bracketed name + a hand-nested
1681
+ schema that Rails' bracket-expansion silently dropped. The client's `meta.changed`
1682
+ resolver (`#changedComputeField`) is now scope-aware too, so a scoped compute
1683
+ component's reducer still sees the bare declared name.
1318
1684
 
1319
- - **Cross-root text mirrors + the `text` client op (#159).** A derived value can
1320
- now be painted into a text node **outside** the computing component's reactive
1321
- root — the read-only recap in another tab pane that previously forced a
1322
- bespoke JS listener — with the library's default-deny posture intact:
1323
- - `reactive_compute ..., mirror: { sum_total: "#sum_total" }` declares
1324
- allowlisted cross-root text mirrors. Each compute pass paints every declared
1325
- name into its document-wide **id** target(s) via `textContent`
1326
- (change-guarded, never `innerHTML`, never blanks a name the pass produced no
1327
- value for). The value comes from the reducer result, a just-written output's
1328
- field, or a declared input's identity value — so it works with no reducer at
1329
- all. Non-id selectors raise at declare time AND are warn-and-skipped by the
1330
- client interpreter (two-sided default-deny). No `mirror:` → the wire is
1331
- byte-identical to before.
1332
- - `js.text(to, value, global: false)` — a new op that sets `textContent`
1333
- (stringified; `nil` clears), available to `on_client`, `reply.js`, and
1334
- `broadcast_js_to`. Strictly less powerful than `set_attr`; pair with
1335
- `global: true` for the cross-root paint.
1336
- - `global: true` is now honored on the `reactive:js` stream path: a single op
1337
- can opt out of the reply's target-root scope to document-wide resolution
1338
- (previously it was silently ignored when a `target` was set).
1685
+ - **`reactive_compute` output order no longer silently corrupts values (#183).**
1686
+ Each output write used to dispatch an `input` event **mid-loop**, re-entering the
1687
+ reducer synchronously from a **half-written DOM** — so a wrong `outputs:` order
1688
+ could commit wrong values (a real money-corruption class), settling only if the
1689
+ reducer was convergent in exactly the declared order. The client now runs the
1690
+ reducer **once** from one pre-write input snapshot, writes all changed outputs as
1691
+ a **batch**, paints text/mirror sinks from the settled values, then dispatches the
1692
+ `input` events — each self-marked so the root skips re-running its own reducer
1693
+ while other listeners (chained repaint, dirty tracking, `reactive_show`, sibling
1694
+ roots) still fire. Declared output order is no longer semantics. Covered by a JS
1695
+ order-independence test (declare outputs in the historically-wrong order — values
1696
+ stay correct) and a self-re-entry-suppression test.
1697
+
1698
+ - **A pending-state text swap no longer destroys a composite trigger's icon (#181).**
1699
+ The former `disable_with:`/`loading:` text swap wrote `trigger.textContent`, which
1700
+ **flattens every child node** — a `<button><svg/> Save</button>` lost its icon the
1701
+ instant the request enqueued, and it never came back on restore. `busy:`'s swap now
1702
+ reads and restores `innerHTML`, so an icon-plus-label trigger round-trips intact.
1703
+ Covered by a new composite-trigger browser assertion and a JS child-preservation
1704
+ unit test.
1705
+
1706
+ - **`exclude:`/`visible_to:` now actually reach pgbus — actor-echo suppression works over SSE (#187).**
1707
+ `broadcast_*_to`/`broadcast_*_to_each` passed `exclude:`/`visible_to:` as keyword
1708
+ arguments to `Turbo::StreamsChannel.broadcast_*_to`, but turbo-rails swallows
1709
+ unknown kwargs into its render locals — so on the pgbus transport the options were
1710
+ **silently dropped** and `exclude: reactive_connection_id` never suppressed the
1711
+ actor's own broadcast echo. They are now threaded through the thread-locals pgbus
1712
+ reads (`Thread.current[:pgbus_broadcast_exclude]`/`_visible_to`), capability-gated
1713
+ on `pgbus_streams?` so the Action Cable path is unchanged. Found by the new
1714
+ end-to-end pgbus transport suite (below) — the prior unit doubles only proved the
1715
+ option was *forwarded to the method*, never that it *reached pgbus*.
1339
1716
 
1340
1717
  ### Performance
1341
1718
 
1719
+ - **A deferred fragment renders as soon as it is read (#336).** The defer lane
1720
+ (a `reply.defer` fetch, a `reactive_lazy` load, a `cache:` fragment) handed
1721
+ its reply to `Turbo.renderStreamMessage`, and Turbo applies each
1722
+ `<turbo-stream>` one animation frame after its `turbo:before-stream-render`.
1723
+ The lane now catches those events and, once every listener has run, calls
1724
+ the final `event.detail.render` itself, in order, in the same task; Turbo's
1725
+ frame-later call gets a no-op. The event is still Turbo's own, so
1726
+ `preventDefault()` cancels a stream and a wrapped `detail.render` (effects,
1727
+ dismissing flashes) still runs. A Turbo that dispatches later renders on its
1728
+ own, as before. A warm `reactive_lazy(on:, cache:)` open in headless
1729
+ Chromium: median time from the trigger to the rows in the DOM 5.9 → 0.8 ms
1730
+ (stream event to rows 5.2 → 0.2 ms). Action replies and broadcasts keep
1731
+ Turbo's timing. On the opt-in client, a stream held for a module on its way
1732
+ wraps a copy of the event's render instead of writing it back, so Turbo's
1733
+ later call stays the no-op. The default bundle grows 128 B gzip
1734
+ (25,429 → 25,557 B), the defer module 129 B (2,845 → 2,974 B), the split
1735
+ core 21 B (13,323 → 13,344 B).
1736
+
1737
+ - **The default client is 1,008 B smaller gzipped (#310).**
1738
+ `reactive_controller.min.js` goes from 25,933 B to 24,925 B. The feature
1739
+ modules keep their per-root state in closures instead of records whose
1740
+ property names the minifier cannot rename (form, bindings, persist, defer;
1741
+ compute's record gets short names), and hints, devtools and bindings drop
1742
+ their per-call context records and wrapper exports. The features are also
1743
+ laid out in the bundle so gzip's 32 KB window reaches the runtime code they
1744
+ repeat (about 450 B of the total). The split client total goes from
1745
+ 32,568 B to 32,027 B; the core is unchanged. Behaviour is unchanged. The
1746
+ 22,700 B target is not met: 2,225 B remain, about 800 B of them from bulk
1747
+ selection (#319), which landed after the target was set.
1748
+ - **A cacheable lazy shell is less than half the size (#306).** A
1749
+ `reactive_lazy(on:, cache:)` shell with `reactive_cache_viewer` and
1750
+ `reactive_cache_version` gzips to about 290 B (was about 580–670 B). The
1751
+ fragment id is the identity encoded once plus a 128-bit MAC (it was the
1752
+ verifier's token wrapped in a second Base64); `u` is 22 base64url characters
1753
+ and `v` 11. A `cache:` shell no longer carries the identity token: a fragment
1754
+ URL the client refuses falls back to a GET of the same id under the fragment
1755
+ path it knows (`cache: "no-store"`), not to the `__materialize` POST, and a
1756
+ failed defer module is reported by its `reactive:error` (`kind: "feature"`)
1757
+ without a POST. Ids minted before this change still verify; their old `u`
1758
+ no longer matches, so such a URL renders but is `no-store`. The
1759
+ deferred-rendering page now says when deferring pays off.
1760
+
1342
1761
  - The defer machinery adds nothing measurable to the existing hot paths
1343
1762
  (same-machine, same-checkout before/after vs `main`): `to_stream_replace`
1344
1763
  14.4k → 14.2k i/s (within ±3.7% noise), identity token 199.9k → 196.7k i/s
@@ -2947,7 +3366,8 @@ room for dogfood fixes.
2947
3366
  scaffolds a reactive component (and an RSpec spec when the app uses RSpec),
2948
3367
  state-backed by default or record-backed with `--record`.
2949
3368
 
2950
- [Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.9.0...HEAD
3369
+ [Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.0...HEAD
3370
+ [0.14.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.9.0...v0.14.0
2951
3371
  [0.9.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.6...v0.9.0
2952
3372
  [0.2.6]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.5...v0.2.6
2953
3373
  [0.2.5]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.4...v0.2.5