phlex-reactive 0.10.0 → 0.11.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42d923475e8b2eb3ea48a0ef076358a10dbfd7760c34709dc54af5910805ad01
4
- data.tar.gz: a6822d8caa3e24dc863284f7bb6eafde39bc69562d4c5d14504b99e489c1e302
3
+ metadata.gz: 4b92414ce55aa10f4ac2fb223ba0cb689928ca773750dd14c7ef80284156f255
4
+ data.tar.gz: bd12836456aa3f2010f8ea86c0f5c3a6a54f7dabfd04f0f5ceb28b6466fb28e6
5
5
  SHA512:
6
- metadata.gz: 234bcb0ca3b36e91f8f53cbbb2dd31f86f03b5a1043911e13652f3b4c2d31fe882b883035c4ab8ba2e2fc8a0f0755ac6302f2bf6d04c09d93aa2d659cd69e358
7
- data.tar.gz: b63a02ff87733de83ad39dbea2f9754ef62fbfc79d7cf8f1d4a98e22891dae50e215bbca11b5faecefe9a454a2b0794c3fbbf870ee4da2ca8ec025aea69185b7
6
+ metadata.gz: 2608d8963434dd4d8a5e015f12e17d54ba7e3c62fdbfab0dca5ffb31c8fd75786eb6467dc864143a2635fa51e1ec574f1ad45a1d804800cb553dcce0fd82f18d
7
+ data.tar.gz: e5ad1eeeaab37b4f69fe1ab5368fced9f427bbe56ead3ef24f63c950ded96d755d14a6f2dc9b6dd5f3a8af7fe3d96cef2eb6112eb623a5513ebba322df823cfd
data/CHANGELOG.md CHANGED
@@ -8,6 +8,204 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Fixed
10
10
 
11
+ - **Scoped form fields no longer silently drop their params — the #67 footgun is fixed (#184).**
12
+ Under `reactive_scope`, `reactive_field(:date)` now emits the scoped wire name
13
+ (`name="invoice[date]"`), so the POST arrives bracketed — and the endpoint unwraps
14
+ exactly ONE scope level before schema matching, so a FLAT schema
15
+ (`params: { date: :string }`) matches. Previously a scoped field either POSTed a bare
16
+ name (misaligned with the `reactive_show`/`reactive_compute` resolvers, which already
17
+ query `[name="scope[x]"]`) or forced a hand-written bracketed name + a hand-nested
18
+ schema that Rails' bracket-expansion silently dropped. The client's `meta.changed`
19
+ resolver (`#changedComputeField`) is now scope-aware too, so a scoped compute
20
+ component's reducer still sees the bare declared name.
21
+
22
+ - **`reactive_compute` output order no longer silently corrupts values (#183).**
23
+ Each output write used to dispatch an `input` event **mid-loop**, re-entering the
24
+ reducer synchronously from a **half-written DOM** — so a wrong `outputs:` order
25
+ could commit wrong values (a real money-corruption class), settling only if the
26
+ reducer was convergent in exactly the declared order. The client now runs the
27
+ reducer **once** from one pre-write input snapshot, writes all changed outputs as
28
+ a **batch**, paints text/mirror sinks from the settled values, then dispatches the
29
+ `input` events — each self-marked so the root skips re-running its own reducer
30
+ while other listeners (chained repaint, dirty tracking, `reactive_show`, sibling
31
+ roots) still fire. Declared output order is no longer semantics. Covered by a JS
32
+ order-independence test (declare outputs in the historically-wrong order — values
33
+ stay correct) and a self-re-entry-suppression test.
34
+
35
+ ### Changed
36
+
37
+ - **BREAKING: small sharp knives — the last 0.11 API-clarity pass (#186).**
38
+ Four independent edges honed, one contract frozen:
39
+ - **`reactive_filter` speaks fields, not selectors.** `reactive_filter(:q)` names the
40
+ driving FIELD and compiles it to `[name="q"]` (scope-aware, like `reactive_field`
41
+ from #184); `option:` defaults to `[role=option]`. The old
42
+ `reactive_filter(input: "#search", …)` selector form raises a guided error. The
43
+ client wire is byte-identical (it still receives selectors) — this is a server-only
44
+ compile, no client change. `group:`/`empty:` stay opt-in (emit only when passed).
45
+ - **`transition:` takes named legs.** `js.toggle("#x", transition: { during:, from:, to: })`
46
+ replaces the positional `transition: [during, from, to]` array (which raises with the
47
+ caller's values slotted into the named form). Compiles to the same wire array — zero
48
+ client change.
49
+ - **BREAKING: one registry reader — the plural frozen hash IS the fetch-one.** The seven
50
+ singular getters (`reactive_action`, `reactive_action?`, `reactive_collection_def`,
51
+ `reactive_collection?`, `reactive_compute_def`, `reactive_compute?`, and the bare
52
+ `reactive_compute(:name)` GETTER form) are removed — each raises a guided error naming
53
+ the hash form (`reactive_actions[:name]`, `reactive_actions.key?(:name)`,
54
+ `reactive_computes[:name]`, `reactive_collections[:name]`). The resolved registries now
55
+ come back FROZEN — they are the memoized dispatch table, so mutation raises `FrozenError`
56
+ and can never corrupt default-deny. The `reactive_compute :name, inputs:, outputs:`
57
+ SETTER is unchanged.
58
+ - **`reply.append`/`reply.prepend` accept row kwargs (non-breaking).** Extra kwargs
59
+ (`reply.append(item, to: :items, autofocus: true)`) now thread through to the row
60
+ component's initializer (`ItemRow.new(item:, autofocus: true)`). Additive — no-kwarg
61
+ calls are unchanged.
62
+ - **`defer.phlex_reactive` joins the frozen instrumentation contract (non-breaking).**
63
+ The deferred-render endpoint's `ActiveSupport::Notifications` event now has a
64
+ request-spec contract test driving all five outcomes (`ok`/`no_content`/`invalid_token`/
65
+ `not_found`/`unauthorized`), freezing its `{ component:, outcome: }` payload shape (a
66
+ rename fails CI, like `action`/`render`/`broadcast`). Documented in the README
67
+ instrumentation table and the observability section of the performance docs page.
68
+
69
+ - **BREAKING: one `broadcast_to` — verbs as kwargs, components as payloads (#185).**
70
+ The 11 `broadcast_*_to` / `broadcast_*_to_each` methods collapse into ONE
71
+ `broadcast_to` where the verb is a kwarg and its value is the payload:
72
+
73
+ ```ruby
74
+ # before # after
75
+ Item.broadcast_replace_to(@list, :todos, Item.broadcast_to(@list, :todos,
76
+ model: @todo, morph: true) replace: @todo, morph: true)
77
+ Row.broadcast_append_to(@list, target: t, Row.broadcast_to(@list, append: item, target: t)
78
+ model: item)
79
+ Counter.broadcast_replace_to_each(keys, Counter.broadcast_to(each: keys, replace: counter)
80
+ model: counter)
81
+ Badge.broadcast_js_to(user, :alerts, ops) Badge.broadcast_to(user, :alerts, js: ops)
82
+ ```
83
+
84
+ A Hash payload is the component's init kwargs verbatim (`update: { room:, author: }`),
85
+ killing the old `**options` collision (a component with an init kwarg named
86
+ `target`/`morph`/`exclude` is broadcastable again). Payloads can be BUILT
87
+ components, and the new module-level `Phlex::Reactive.broadcast_to(@list, :todos,
88
+ update: TodoCount.new(...), target: "todos-count")` broadcasts a NON-Streamable
89
+ component (a count badge) — instrumented — without hand-rolling the raw channel +
90
+ render. Self-targeting verbs (`replace:`/`remove:`) require a Streamable payload
91
+ (its `#id` is the target); container verbs (`update:`/`append:`/`prepend:`) take any
92
+ component. Every removed method raises a guided error printing the `broadcast_to`
93
+ rewrite. `exclude:`/`visible_to:` still thread to pgbus through the capability-gated
94
+ thread-local path — unchanged (verified against pgbus 0.11.0).
95
+
96
+ - **BREAKING: `to_stream_morph` is removed — morph is a kwarg (#185).**
97
+ Use `to_stream_replace(morph: true)` (byte-identical wire). `reply.morph` /
98
+ `reply.replace(morph: true)` are unchanged.
99
+
100
+ - **BREAKING: forms & fields — scope everywhere, one dirty declaration, named schemas (#184).**
101
+ - **`reactive_scope` extends to `reactive_field` and the param unwrap:** a scoped
102
+ field emits `name="scope[field]"` and the endpoint peels one scope level, so the
103
+ schema stays flat. A schema nested under the scope key raises a guided
104
+ `ArgumentError` from BOTH the `action` macro and `reactive_scope` (either
105
+ declaration order).
106
+ - **One dirty declaration — `reactive_dirty`:** `reactive_dirty warn_unsaved: true`
107
+ (class-level) and `reactive_dirty only: %i[title]` replace
108
+ `reactive_root(track_dirty:, warn_unsaved:)` + `reactive_field(dirty:)`. The
109
+ removed kwargs raise guided errors; the emitted DOM is unchanged (zero client
110
+ change).
111
+ - **Named param schemas — `Phlex::Reactive.param_schema`:** register a reusable
112
+ schema in an initializer (`param_schema :todo, title: :string, …`) and resolve it
113
+ with `action :save, params: :todo`, or compose with `{ **param_schema(:todo), … }`.
114
+ Frozen after boot (the `param_type` precedent); an unknown name lists the
115
+ registered ones. `params:` still takes a Hash.
116
+ - **One binding helper:** `reactive_input` / `reactive_select` are removed — use
117
+ `input(**reactive_field(…))` / `select(**reactive_field(…)) { … }` /
118
+ `textarea(**reactive_field(…))`. The stubs raise the rewrite.
119
+
120
+ - **BREAKING: `reactive_compute` is scope-aware, root-bound, and permit-shaped (#183).**
121
+ - **Bind + listen at the root:** `reactive_root(compute: :name)` emits the compute
122
+ descriptors AND the `input->reactive#recompute` delegation, so fields carry
123
+ ZERO per-field wiring. Conditional binding collapses to one expression
124
+ (`reactive_root(compute: (:split unless @order.persisted?))`; `nil` = no binding).
125
+ `reactive_compute_attrs(...)` raises a guided error naming the new form.
126
+ - **Scope-resolved names:** under `reactive_scope :order`, a bare compute input/
127
+ output `cash` resolves as `[name="order[cash]"]` client-side (the same
128
+ convention `reactive_show`/`reactive_field` use); a bracketed literal passes
129
+ through unscoped.
130
+ - **Permit-style inputs:** `inputs: [:qty, title: :string]` — bare symbols default
131
+ to `:number`, a trailing Hash types the exceptions. The old array form
132
+ (`%i[a b]`) and hash form (`{ a: :number }`) are degenerate cases (zero shim,
133
+ byte-identical wire).
134
+ - **Sinks declare themselves:** every reducer result key paints into any matching
135
+ sink — an owned field iff in `outputs:` (the allowlist), any owned
136
+ `reactive_text` node by presence, any declared `mirror:` id. An `outputs:` entry
137
+ that existed only to reach a text node is now redundant (old declarations keep
138
+ working). `reactive_text(:name)` with no explicit initial seeds its first paint
139
+ from `reactive_values` when covered.
140
+
141
+ - **BREAKING: `reply` is the only door — the `Response` class verbs are removed (#182).**
142
+ `Phlex::Reactive::Response.replace(self)` / `.morph` / `.update` / `.remove` /
143
+ `.redirect` / `.with` / `.streams` (and the collection class methods) are removed
144
+ as public entry points — each raises a guided `ArgumentError` naming the
145
+ `reply.<verb>` rewrite. Actions return `reply.<verb>` (the subject-bound door added
146
+ earlier); the immutable `Response` value object and its instance chain
147
+ (`.flash`/`.stream`/`.also`/`.js`/`.defer`) are unchanged, so the endpoint reads it
148
+ exactly as before. One concept, one entry point.
149
+
150
+ - **BREAKING: reactive-collection replies read as Ruby — `to:`/`from:` keywords (#182).**
151
+ The collection name moved from a leading Symbol to a keyword, and the `UNSET`
152
+ sentinel that overloaded `reply.remove` is gone (dispatch keys on `from:`'s
153
+ presence):
154
+
155
+ | Before (removed) | After |
156
+ |---|---|
157
+ | `reply.append(:items, item)` | `reply.append(item, to: :items)` |
158
+ | `reply.prepend(:items, item)` | `reply.prepend(item, to: :items)` |
159
+ | `reply.remove(:items, id)` | `reply.remove(id, from: :items)` |
160
+ | `reply.remove` (bare) | `reply.remove` — unchanged (removes self) |
161
+
162
+ A Symbol in the model position (the old shape) raises a guided rewrite.
163
+
164
+ - **BREAKING: one flash contract — `flash_component` is a callable (#182).**
165
+ `Phlex::Reactive.flash_component` is now a lambda the app owns, so the gem no
166
+ longer guesses your component's kwargs (the old hardcoded `new(level:, content:)`
167
+ collided with real flash components):
168
+
169
+ ```ruby
170
+ # before: Phlex::Reactive.flash_component = MyFlash (gem calls MyFlash.new(level:, content:))
171
+ # after:
172
+ Phlex::Reactive.flash_component = ->(level, content) { MyFlash.new(level:, message: content) }
173
+ ```
174
+
175
+ Assigning a Class raises a guided error printing the one-line lambda. The internal
176
+ flash builders (`flash_stream`/`flash_html`/`default_flash_html`) are now private.
177
+
178
+ - **BREAKING: `also_update` / `also_replace` collapse into one `.also` (#182).**
179
+ The lying `html:` kwarg (it accepted components too) and the per-companion method
180
+ choice are gone:
181
+
182
+ ```ruby
183
+ # before
184
+ reply.replace.also_update("page_heading", html: @account.name)
185
+ reply.replace.also_replace(SummaryCard.new(account: @account), morph: true)
186
+ # after
187
+ reply.replace.also(page_heading: @account.name) # target => content
188
+ reply.replace.also(SummaryCard.new(account: @account), morph: true) # a component at its own id
189
+ ```
190
+
191
+ String content is HTML-escaped, component content rendered — the escaping contract
192
+ is verbatim. `also_update`/`also_replace` raise guided rewrites.
193
+
194
+ - **BREAKING: the `flash_builder` / `reset_flash_builder!` aliases are removed (#182).**
195
+ They were "permanent aliases" for `stream_builder` / `reset_stream_builder!` that
196
+ contradicted the clean-break rule. Each old name now raises a guided `NoMethodError`
197
+ naming the real method.
198
+
199
+ ### Fixed
200
+
201
+ - **A pending-state text swap no longer destroys a composite trigger's icon (#181).**
202
+ The former `disable_with:`/`loading:` text swap wrote `trigger.textContent`, which
203
+ **flattens every child node** — a `<button><svg/> Save</button>` lost its icon the
204
+ instant the request enqueued, and it never came back on restore. `busy:`'s swap now
205
+ reads and restores `innerHTML`, so an icon-plus-label trigger round-trips intact.
206
+ Covered by a new composite-trigger browser assertion and a JS child-preservation
207
+ unit test.
208
+
11
209
  - **`exclude:`/`visible_to:` now actually reach pgbus — actor-echo suppression works over SSE (#187).**
12
210
  `broadcast_*_to`/`broadcast_*_to_each` passed `exclude:`/`visible_to:` as keyword
13
211
  arguments to `Turbo::StreamsChannel.broadcast_*_to`, but turbo-rails swallows
@@ -21,6 +219,32 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
21
219
 
22
220
  ### Added
23
221
 
222
+ - **Conditional confirm — warn only when the values look suspect (#179).**
223
+ `confirm:` now takes a Hash for soft-validation-before-submit, so the dialog fires
224
+ ONLY when the field values are wrong — instead of a hand-written submit handler that
225
+ inspects fields and calls `confirm()` itself. Two forms, both evaluated client-side
226
+ over the same collected fields `reactive_compute` reads:
227
+ - **Declarative** — `confirm: { when: { total: 0 }, message: "Total is 0 — continue?" }`.
228
+ `when:` reuses `reactive_show`'s conditions language verbatim: a scalar is equals,
229
+ a `Range` is a threshold (`qty: 100..`), an `Array` is membership. Zero JS. The
230
+ dialog fires when the condition MATCHES; a clean value submits silently.
231
+ - **Named predicate** — `confirm: { predicate: "end_before_start", message: "…" }` for
232
+ multi-field logic the single-field form can't express. Register a pure function at
233
+ boot (`setConfirmPredicate("end_before_start", ({ starts_at, ends_at }) => ends_at < starts_at)`),
234
+ the twin of `setComputeReducer`. An unregistered name warns and proceeds without a
235
+ dialog. Works on `on(...)` AND `on_client(...)`. The predicate is soft-validation UX,
236
+ **not authorization** — a user can bypass it and the action still hits the endpoint's
237
+ real authorize/default-deny; never let it stand in for a server-side check.
238
+
239
+ - **`confirm:` on `on_client(...)` — themed confirmation for zero-round-trip client ops (#178).**
240
+ The client-op path gains the SAME overridable `confirmResolver` gate `on(:action, confirm:)`
241
+ has (#52/#55). A destructive-feeling client op (clear a draft, reset a form) gets the
242
+ app's themed dialog with one line and no round trip:
243
+ `button(**on_client(:click, js.text("#draft", ""), confirm: "Discard this draft?"))`.
244
+ The gate lives in the user-gesture path (`runOps`), never in the shared op applier — a
245
+ server-pushed `reactive:js` op stream must not prompt. `setConfirmResolver` now themes
246
+ both paths at once.
247
+
24
248
  - **CI transport verification matrix — the browser suite runs on Action Cable AND pgbus (#187).**
25
249
  The system job is now `server × transport` (Puma/Falcon × cable/pgbus). The pgbus
26
250
  cells add a plain `postgres:18` (pgbus vendors the PGMQ schema via its own
@@ -34,6 +258,28 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
34
258
 
35
259
  ### Changed
36
260
 
261
+ - **BREAKING: ONE pending-state vocabulary — `busy:` replaces `loading:` / `disable_with:` (#181).**
262
+ A trigger's declarative pending affordance is now a single `on(…, busy:)` kwarg
263
+ that shares `optimistic:`'s key vocabulary and normalizer — the only difference
264
+ is the lifecycle (`busy:` reverts on **settle**, `optimistic:` on **failure**).
265
+ `busy:` takes a **String shorthand** (`busy: "Saving…"` ≡ `{ disable: true, text:
266
+ "Saving…" }`) or a **Hash** with the same keys as `optimistic:`
267
+ (`add_class:`/`remove_class:`/`toggle_class:`/`hide:`/`show:`/`disable:`/`text:`/`to:`).
268
+ The removed `loading:` (with its odd `class:` key), `disable_with:`, and the
269
+ `on(…, listnav:)` kwarg each raise a **guided `ArgumentError`** printing the exact
270
+ rewrite. Combobox keyboard nav is now the standalone `reactive_listnav` (composed
271
+ via `mix`), which defaults its option selector to `[role=option]`. Migration:
272
+
273
+ | Before (removed) | After |
274
+ |---|---|
275
+ | `on(:save, disable_with: "Saving…")` | `on(:save, busy: "Saving…")` |
276
+ | `on(:save, loading: { disable: true, class: "opacity-50", text: "Saving…" })` | `on(:save, busy: { disable: true, add_class: "opacity-50", text: "Saving…" })` |
277
+ | `on(:search, event: "input", listnav: "[role=option]")` | `mix(on(:search, event: "input"), reactive_listnav)` |
278
+
279
+ The wire attribute is renamed `data-reactive-loading-param` → `data-reactive-busy-param`;
280
+ the client keeps a **read shim** for the old attribute for one minor so an
281
+ in-flight page rendered by the previous gem across a deploy keeps its affordance.
282
+
37
283
  - **BREAKING: `reactive_show` speaks ONE conditions language — `if:`/`if_any:`/`unless:` (#180).**
38
284
  The four accreted dialects (the positional-field predicate kwargs
39
285
  `equals:`/`not:`/`in:`/`gte:`/`gt:`/`lte:`/`lt:`, the `all:`/`any:` term