phlex-reactive 0.10.0 → 0.11.1

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