phlex-reactive 0.15.1 → 0.15.3

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +188 -0
  3. data/README.md +82 -9
  4. data/app/controllers/phlex/reactive/actions_controller.rb +4 -1
  5. data/app/javascript/phlex/reactive/compute.js +14 -1
  6. data/app/javascript/phlex/reactive/compute.min.js +2 -2
  7. data/app/javascript/phlex/reactive/compute.min.js.map +3 -3
  8. data/app/javascript/phlex/reactive/core.min.js +4 -3
  9. data/app/javascript/phlex/reactive/core.min.js.map +3 -3
  10. data/app/javascript/phlex/reactive/features/bindings.js +66 -0
  11. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  12. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  13. data/app/javascript/phlex/reactive/features/form.js +54 -22
  14. data/app/javascript/phlex/reactive/features/form.min.js +2 -2
  15. data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
  16. data/app/javascript/phlex/reactive/features/persist.js +31 -2
  17. data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
  18. data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
  19. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -3
  20. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +6 -6
  21. data/app/javascript/phlex/reactive/runtime.js +476 -13
  22. data/lib/phlex/reactive/component/helpers.rb +24 -5
  23. data/lib/phlex/reactive/component/identity.rb +34 -1
  24. data/lib/phlex/reactive/component/image_fallback_binding.rb +54 -0
  25. data/lib/phlex/reactive/component/media_binding.rb +103 -0
  26. data/lib/phlex/reactive/component/media_pattern.rb +79 -0
  27. data/lib/phlex/reactive/doctor.rb +20 -8
  28. data/lib/phlex/reactive/held_fields.rb +87 -0
  29. data/lib/phlex/reactive/js.rb +153 -29
  30. data/lib/phlex/reactive/streamable.rb +26 -2
  31. data/lib/phlex/reactive/test_helpers.rb +11 -2
  32. data/lib/phlex/reactive/version.rb +1 -1
  33. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b268544642595b7c8912db0dcbee175ed751fb73290d1bfcfec0c4c9627583b
4
- data.tar.gz: 459a72f7b048c8db6010213e252d09c552eefb3296eb838bc36973d9ded2a48d
3
+ metadata.gz: 2fb24b631e6cd4fec31a0635a1d522f3b3423cdf67ad1e41eecb279fcc1c279b
4
+ data.tar.gz: 82f859da94e60c3a691d3b39a9aa51ead6e52fc44c3ead8b32d23b1f5b079143
5
5
  SHA512:
6
- metadata.gz: dfcf217a1d1dd7fae8a79c5a4d146409a5528a13a0a7f6cadfa757ba2728c740e90fb3e95ed6957740b4219c9ea7ac5ad0bf3c5c719442671662a1f803b6a6e8
7
- data.tar.gz: 6636ad3074cd8cba009c507730d901c38afc343b228e0780c4cf50b7404a6b0f1c8f61e75c3b5edeeb5f0fa1a56f5966ea15ed53a8db5ee4987fe320fde85dc5
6
+ metadata.gz: 03f24931aedcb275d1e938ededb4adfb0820660114373a96187999c3eaeb30fc532a26b6aee61dc4eb222ddb06accaf2f1c0242d8296329b8a55cf0bf3f5b836
7
+ data.tar.gz: 4afa1a62e2872e6ba17394e342d41aa129d9377a1cfe47681f28f18c6caa3fefa0f5e19a94cd1b503048a7219b4e771eb5a4abbb72337b2f4a99bf99f5d1fb82
data/CHANGELOG.md CHANGED
@@ -8,6 +8,115 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **An image fallback that recovers — `reactive_image_fallback(hide: true, show_alt: nil)` (#383).**
12
+ `img(**reactive_image_fallback)` marks an `<img>` whose `src` fails with
13
+ `data-reactive-image-state="error"` and hides it; `show_alt: "#bank-name"`
14
+ also writes its `alt` (as `textContent`) into that node inside the root and
15
+ shows it; `hide: false` only sets the state, for CSS. `load` sets
16
+ `"loaded"`. A later `src` tries again: `copy_media`, `reactive_media` and
17
+ `preview_file` clear the error as they write the `src`, any `src` that loads
18
+ clears it, and a morph that changes the `src` lets the browser retry (one
19
+ that keeps it keeps the state). Removing the `src` is not an error. Only the
20
+ `hidden` the fallback added is ever removed. Composes with `reactive_media`
21
+ (`reactive_media(…, **reactive_image_fallback)`). The client listens once on
22
+ the document in the capture phase (`error`/`load` do not bubble) and reads
23
+ images that failed before it registered; nothing runs per root at connect.
24
+ No URL in any argument; `set_attr` still refuses `src`/`srcset`. `hide:`
25
+ that is not a boolean, or a `show_alt:` that is not a non-blank String (or
26
+ is `@root`), raises at render; a String that is not a valid CSS selector is
27
+ ignored by the client. Client: +597 B gzipped in the default bundle,
28
+ +550 B in the split core. Docs: "An image that falls back, and recovers" on
29
+ the client-ops example.
30
+
31
+ - **Media previews — `js.copy_media` and `reactive_media` (#373).**
32
+ `js.copy_media("#logo", from: "#logo-007")` copies `src`, `srcset` and `alt`
33
+ from an `<img>` the server rendered onto a preview `<img>`; the URL is read
34
+ from the live source element and is never an op argument, so `set_attr`
35
+ still refuses `src` (#96). A missing source or a non-`<img>` source clears
36
+ the target (an `<img>` source without `src` keeps its `alt`, #381); a target that is not an `<img>` (an
37
+ `iframe`, `script`, `object`, `embed`, `frame`) is warned about and skipped;
38
+ the same URL twice is not written again. `img(**reactive_media(from: :code,
39
+ take: 3, pool: { "007" => "#logo-007" }, label: "#caption", blank_label:
40
+ "Unknown"))` is a binding: the field's value (its first `take:` characters)
41
+ picks the pool image by key, a miss clears the preview, and `label:` gets
42
+ the alt via `textContent`. Pool values are selectors only. `reactive_media`
43
+ raises at render without `pool:` or with a `take:` that is not a positive
44
+ Integer. `set_attr` / `toggle_attr` / `remove_attr` now refuse `srcset`
45
+ too, on both sides (a URL list that picks what an `<img>` loads); copy it
46
+ with `copy_media`. Client: +416 B gzipped in the default bundle, +170 B in the split
47
+ core, +231 B in the bindings module. Docs: "Media previews" on the
48
+ client-ops example.
49
+
50
+ - **Preview a chosen file — `js.preview_file(to, into:, name: nil, global: false)` (#375).**
51
+ `on_client(:change, js.preview_file("#photo-file", into: "#photo", name:
52
+ "#photo-name"))` paints the input's first `image/*` file onto the `img`
53
+ before any upload, through an object URL the client makes from the `File`
54
+ itself, and puts `file.name` in the `name:` node. An empty selection or a
55
+ non-image file removes the `src` and clears the name. The runtime keeps the
56
+ last URL per `img` and revokes it on a re-pick, a clear, and when the `img`
57
+ leaves the page or a morph strips its `src`. The op carries no URL (a `url`
58
+ key on a raw op list is ignored), `set_attr` of `src` (and, with #373, `srcset`) still raises, and only
59
+ an `<img>` ever gets a `src`. Actor-only: `broadcast_to(js:)` refuses it.
60
+ Client: +323 B gzipped in the default bundle, +356 B in the split core (on
61
+ top of #369, #370 and #374).
62
+ Docs: "Preview a chosen image" on the client-ops example.
63
+
64
+ - **`js.set_value(to, value)` — a field's live value from a client op
65
+ (#374).** `on_client(:click, js.set_value("#notes", "Thanks for your
66
+ order.").set_value("#template_key", "thanks"))` fills a field from text
67
+ already on the page, with no clipboard and no round trip. It assigns the
68
+ IDL `.value` of an `input`, `textarea` or `select` (never the attribute,
69
+ never parsed as HTML; the value is stringified, `nil` and `""` clear), then
70
+ dispatches one bubbling `input` when the value changed (for a
71
+ `<select multiple>`, when its selection changed), so compute reducers
72
+ and `reactive_show` react as if the user typed. It never dispatches
73
+ `change` and never moves focus. A `select` selects the matching option; a
74
+ file input accepts `""` only. A `contenteditable`, any other non-field, and
75
+ a non-empty value for a file input warn and are skipped; `:root` raises at
76
+ render. Actor-only: `on_client`, `reply.js` and a reducer's `$ops` (the
77
+ compute builder gains `ops.set_value`) carry it, `broadcast_to(js:)`
78
+ refuses it. `js.set_attr(…, "value", …)` is unchanged and still writes only
79
+ the attribute. Client: +134 B gzipped in the default bundle, +117 B in the
80
+ split core (on top of #369 and #370). Docs: the client-ops example.
81
+
82
+ - **`reactive_held_fields` — the action knows when a morph held a focused
83
+ field over a changed server value (#370).** Since #338 a morph keeps the
84
+ value of the field being typed in; when the render's value differs from the
85
+ field's default, another tab changed it while the user typed, and the next
86
+ save silently overwrote that change. The client now marks such a field
87
+ `data-reactive-held` (kept through later morphs while focused) and
88
+ dispatches a bubbling `reactive:held` with `{ name, theirs }`; a render
89
+ equal to the default marks nothing. The root's next action request names its
90
+ held fields in an `X-Reactive-Held` header (absent when nothing is held, so
91
+ every other request is byte-identical), and `reactive_held_fields` in the
92
+ action returns them (`[]` without the header), so the action can refuse or
93
+ merge. The marks clear once the reply to the root's own request is applied
94
+ (that reply never marks), or when a field is set back to its default; an
95
+ error reply never marks and keeps them. Broadcast with
96
+ `exclude: reactive_connection_id` (pgbus): without it (Action Cable), an
97
+ echo of an older save landing after a newer reply can mark too. The
98
+ header is untrusted: names only, capped, and filtered to the action's
99
+ declared params. `run_reactive(form, :save, held: ["title"], …)` stands in
100
+ for it in unit tests. Client: +534 B gzipped in the default bundle, +559 B
101
+ in the split core (on top of #369). Docs: "Knowing what was held" under Morphing on the
102
+ architecture page.
103
+
104
+ - **`keep:` on client ops — a morph no longer undoes them (#369).**
105
+ `js.add_class("#item-2", "selected", keep: true)` (and `keep:` on
106
+ `remove_class`/`toggle_class`, `show`/`hide`/`toggle`,
107
+ `set_attr`/`remove_attr`/`toggle_attr`, in the Ruby builder and the client
108
+ `ops` builder) records what the op left on each target — the class on or
109
+ off, `hidden`, the attribute's value or its absence — and the client puts it
110
+ back after Turbo morphs that element (a `reply.morph`, a
111
+ `broadcast_to(…, morph: true)`, a page-refresh morph). A selection, an expanded row
112
+ or a highlighted item now survives another viewer's broadcast. Classes and
113
+ attributes no kept op touched still follow the render; a later kept op on the
114
+ same class or attribute replaces the earlier one ("clear all, then select
115
+ one"); an element a stream replaces starts clean. `broadcast_to(js:)` refuses
116
+ `keep:` with an `ArgumentError`. Without `keep:` the wire is byte-identical.
117
+ Client: +251 B gzipped in the default bundle, +247 B in the split core.
118
+ Docs: "Keeping client state through a morph" on the client-ops example.
119
+
11
120
  - **Modal dialogs — `js.show_modal` / `js.close_dialog` and
12
121
  `data-reactive-modal` (#365).** `js.show_modal("#picker")` calls
13
122
  `HTMLDialogElement#showModal()`, so an `on_client` dialog gets the browser's
@@ -126,6 +235,48 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
126
235
 
127
236
  ### Fixed
128
237
 
238
+ - **`reactive_media` waits for `take` characters before calling a key a miss
239
+ (#380).** A value shorter than `take:` sliced to a short key, missed the
240
+ pool and wrote `blank_label` while the user was still typing. It is now
241
+ *pending*: `blank_label` only names a full-length key that misses. New
242
+ options: `match:` (a Regexp, anchored to the whole key; a key it rejects is
243
+ pending too) and `pending: :clear` (default: clear the preview, empty the
244
+ label) or `:keep` (leave both alone). The preview carries
245
+ `data-reactive-media-state="pending|hit|miss"`, so a row can hide itself
246
+ with CSS. `match:` reaches the browser as a JS pattern: its `\A`/`\z`
247
+ anchors are dropped (a trailing `\Z` becomes `\n?`), and what JS reads
248
+ differently raises at render (options, escapes such as `\h`/`\p{…}`/`\s`/
249
+ `\u{…}`, POSIX and nested classes, `&&`, `(?i)`/`(?>…)`, characters above
250
+ U+FFFF). **Behaviour change:** with `take:`, a short value now leaves the
251
+ label empty instead of writing `blank_label`. Without `take:` or `match:`
252
+ nothing changes. Client: +160 B gzipped in the default bundle (+152 B on
253
+ top of #381/#382), +152 B in the bindings module, the split core unchanged. Docs: "Media
254
+ previews" on the client-ops example.
255
+
256
+ - **A pool `<img>` with an `alt` and no `src` keeps its name (#381).**
257
+ `js.copy_media` and `reactive_media` treated a source `<img>` without `src`
258
+ as a clear: the preview's `alt` was emptied and `label:` got `blank_label`,
259
+ so a known entry with no logo file lost its name. Such a source is now a hit
260
+ without an image: the target's `src` and `srcset` are removed, its `alt` is
261
+ set, and `label:` gets that `alt`. Only a pool miss, a selector that matches
262
+ nothing, or a source that is not an `<img>` clears. Client: +1 B gzipped in
263
+ the default bundle, −2 B in the split core; the bindings module is
264
+ unchanged.
265
+
266
+ - **`js.preview_file` follows an app's upload validation (#382).** A preview
267
+ ran once, on `change`, so a file the app then rejected (cleared with
268
+ `input.value = ""`, no new `change`) stayed on screen. Two pieces fix it.
269
+ `js.preview_file(…, valid: true)` treats an input whose `validity.valid` is
270
+ false (`setCustomValidity`, `required`) as empty: it never gets a preview,
271
+ and one it had is cleared and its URL revoked. And `reactive:file-changed`
272
+ is the documented event for "the app changed this input's files without a
273
+ `change` event": bind the op to it as well,
274
+ `mix(on_client(:change, op), on_client("reactive:file-changed", op))`, and
275
+ the app dispatches it after it clears or invalidates the field. That works
276
+ whichever `change` listener runs first. Without `valid:` the op behaves as
277
+ before. Client: +22 B gzipped in the default bundle, +34 B in the split
278
+ core (on top of #381). Docs: "Preview a chosen image" on the client-ops example.
279
+
129
280
  - **A stream that renders into a connected root wires what its content needs
130
281
  (#356).** A root read its feature markers only at connect and after a morph
131
282
  of itself, so a `reactive_show` target, select-all/count group, filter,
@@ -162,6 +313,43 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
162
313
  first paint already were. A target with any value term keeps the skip when
163
314
  none of its fields is owned.
164
315
 
316
+ ### Performance
317
+
318
+ - **Guarded and persisted forms connect in linear time.** Each `warn_unsaved`
319
+ root added its own `beforeunload` / `turbo:before-visit` pair on `window`,
320
+ and each persisted root its own `turbo:submit-end` listener on `document`.
321
+ A browser scans a target's listeners on every add and remove, so connecting
322
+ and disconnecting N of them was quadratic. One shared listener per window or
323
+ document now serves them all, added with the first root and removed with
324
+ the last; behaviour is unchanged (live dirty count, one prompt per visit,
325
+ #298). Headless Chromium, insert + connect: 4,000 guarded roots ~306 ->
326
+ ~227 ms, 4,000 persisted roots ~280 -> ~230 ms. +194 B gzipped in the
327
+ default bundle (110 B for the guard, 84 B for persist).
328
+ - **A record-backed token no longer builds a GlobalID URI.** For a record
329
+ whose `to_gid` is GlobalID's own and whose id is an Integer, the gid is
330
+ built directly as `gid://<app>/<Class>/<id>` (byte-identical, pinned by
331
+ `identity_gid_spec.rb`); anything else still calls `to_gid`. The token goes
332
+ from ~11.4 µs and 54 objects to ~5.2 µs and 20 (in-place A/B).
333
+ - **`rake bench:browser`.** Connect, disconnect and a heap soak for the
334
+ shipped client in headless Chromium. The happy-dom connect bench is
335
+ engine-relative and missed the quadratic above; this is the one to trust.
336
+ - **The doctor's authorization check is linear, not quadratic.** It rebuilt
337
+ the Inspector's whole component list (a Prism scan per action) once for
338
+ every component. It now reads it once: `doctor.checks` over the dummy app's
339
+ 90 components drops from 1,337 ms to 55 ms, and an app with a few hundred
340
+ components no longer waits tens of seconds on `rake phlex_reactive:doctor`.
341
+ - **`on()` checks for removed keywords without building arrays.** Every
342
+ trigger ran `params.keys & REMOVED_ON_KWARGS.keys`. It now returns at once
343
+ when there are no extra keywords. `on(:increment)` allocates 5 objects
344
+ instead of 8; a 7-trigger component's `render_component` 118 instead of 136
345
+ (~5% faster in an in-place A/B, a method-level win that shows on broadcast
346
+ fan-out, not request latency).
347
+ - **The request bench measures production, not a file watcher.** The dummy
348
+ app's test env left code reloading on, so every request globbed `app/`:
349
+ `rake bench:request` read ~1.7-2.0 ms and 1,272 objects per state-backed
350
+ action, against ~0.30-0.35 ms and 824 objects with it off (same machine,
351
+ interleaved). Test-env only; the gem is unchanged.
352
+
165
353
  ## [0.14.1] - 2026-10-07
166
354
 
167
355
  ### Added
data/README.md CHANGED
@@ -684,11 +684,13 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
684
684
  | `busy_on(:save)` | Mark any element so it carries `data-reactive-busy` **only while `save` is in flight** — a spinner styled with pure CSS, zero Ruby. See [Loading states](#declarative-loading-states-loading--disable_with). |
685
685
  | `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
686
686
  | `on_client(:click, js.toggle("#menu"))` | **Client-only** trigger: applies declared DOM ops with ZERO round trip — no token, no POST, ever. Takes the same `window:`/`once:`/`outside:` modifiers, plus `detail:` (run only when `event.detail` carries the listed values). See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). |
687
- | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:` and `expanded:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names; `toggle_attr` also flips between two values), `focus`/`focus_first`, `show_modal`/`close_dialog` (a real modal `<dialog>`), `text` (set `textContent` — XSS-safe), `dispatch`, `submit` (requestSubmit the target's own form, optionally through a `submitter:`), `paste_into` (read the clipboard into a field, gesture-gated), `check_group` (tick or untick a checkbox group, then re-sync its bindings), and `persist_state`/`persist_clear` (the `reactive_persist` draft) — chainable. |
687
+ | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:` and `expanded:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names; `toggle_attr` also flips between two values), `focus`/`focus_first`, `show_modal`/`close_dialog` (a real modal `<dialog>`), `copy_media` (copy `src`/`srcset`/`alt` from a server-rendered `<img>`, no URL argument), `text` (set `textContent` — XSS-safe), `set_value` (set a field's live `.value` and dispatch `input`), `dispatch`, `submit` (requestSubmit the target's own form, optionally through a `submitter:`), `paste_into` (read the clipboard into a field, gesture-gated), `preview_file` (paint a chosen image file onto an `img` through an object URL the client makes and revokes), `check_group` (tick or untick a checkbox group, then re-sync its bindings), and `persist_state`/`persist_clear` (the `reactive_persist` draft) — chainable. `keep: true` on the visibility, class and attr ops makes their result survive a morph. |
688
688
  | `reactive_field(:param, **attrs)` | The attribute hash that binds a control to an action param (no magic `name:`) — spread onto any control: `input(**reactive_field(:value, value: @record.name))`, `select(**reactive_field(:status)) { … }`. |
689
689
  | `reactive_editable(:name, multiline: false, placeholder: nil, on_enter: nil, on_escape: :revert, spellcheck: true)` | A plain-text **editable element** as a field (issue #351): `h3(**reactive_editable(:title)) { @card.title }`. Its text travels under the scope-aware `name`: with `multiline: true` its line breaks are sent as `\n`; a single-line field (the default) folds them to spaces; a morph keeps the focused element's text and caret (attributes still morph); paste is plain text; Enter runs `on_enter:` (a single-line field swallows it); Escape reverts. `reactive_dirty` and `reactive_persist` treat it like an input. See the [Editable text](https://phlex-reactive.zoolutions.llc/docs/editable-text) page. |
690
690
  | `reactive_text(:name, initial)` | Mirror a compute output (or a declared input) into a **text node** — a live preview heading, a character counter, `"Hello, {name}"` — via `textContent` (XSS-safe). The text sibling of `reactive_field`; carries no `name`, so it's never POSTed. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
691
691
  | `reactive_show(if:/if_any:/unless:)` | **Value-conditional visibility** (the `x-show`/`data-show` case): spread onto the element to show/hide — it toggles `hidden` from the fields' **current values**, client-only, zero round trip. One conditions language: a **Hash is an AND**, an **Array is membership**, a **Range is a threshold**, `if_any:` is OR-of-AND, `unless:` negates. `reactive_values` computes first paint; `disable:` disables a hidden section's controls. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
692
+ | `reactive_media(from:, pool:, take:, match:, pending:, label:, blank_label:)` | A preview `<img>` that **follows a field** (#373): the field's value (its first `take:` characters, or all of it) is a key into `pool`, whose values are **selectors** for `<img>` elements the server rendered — never URLs. A hit copies that image's `src`/`srcset`/`alt` like `js.copy_media` (a pool `<img>` with an `alt` and no `src` is a hit without an image, #381); a miss clears the preview. `label:` gets the `alt` (or `blank_label`) as `textContent`. Below `take:` characters, or while `match:` (a JavaScript-compatible Regexp over the whole key) rejects the key, the binding is **pending** (#380) — never `blank_label` (Regexp options or Ruby-only syntax raise at render); `pending: :clear` (default) clears the preview and label, `:keep` leaves them. The preview's `data-reactive-media-state` reads `pending`/`hit`/`miss`, so CSS can hide its row. See [Media previews](https://phlex-reactive.zoolutions.llc/docs/example-client-ops). |
693
+ | `reactive_image_fallback(hide: true, show_alt: nil)` | An `<img>` that **falls back and recovers** (#383): on `error` it gets `data-reactive-image-state="error"`; `hide:` (default `true`) also hides it, and `show_alt:`, independently, writes its `alt` into that node and shows it; `load` sets `"loaded"`. A later `src` (from `copy_media`, `reactive_media`, `preview_file`, a morph, or any `src` that loads) shows it again; removing the `src` is not an error. Composes with `reactive_media(…, **reactive_image_fallback)`. See [Client-only ops](https://phlex-reactive.zoolutions.llc/docs/example-client-ops). |
692
694
  | `reactive_enable(if:/if_any:/unless:)` | The sibling of `reactive_show` that flips the element's **own `disabled`** (a button, a fieldset) instead of `hidden` — same conditions, same `reactive_values` first paint. `button(**reactive_enable(if: { "ids[]" => { checked: 1.. } }))`. See [Bulk selection](#bulk-selection-reactive_select_all-reactive_count-reactive_enable). |
693
695
  | `reactive_select_all("ids[]")` / `reactive_count("ids[]")` | A checkbox **group**'s header box (ticks/unticks every owned box; shows checked / indeterminate / unchecked) and its ticked count as text — re-synced on every change and when rows are appended or removed. See [Bulk selection](#bulk-selection-reactive_select_all-reactive_count-reactive_enable). |
694
696
  | `reactive_group_targets("ids[]", count: "#c", enable: { "#b" => 1.. })` | Spread on the root that **owns** a checkbox group: drives a count and an enable on outside, id-allowlisted elements (a bulk bar the page renders). `reactive_group_target_attrs` gives that outside markup its first paint. See [A bulk bar outside the list's root](#a-bulk-bar-outside-the-lists-root-reactive_group_targets). |
@@ -1258,10 +1260,19 @@ button(**on_client(:click, js
1258
1260
  values raise at build time. The **attribute name is allowlisted, enforced twice** — at
1259
1261
  build time in Ruby (an offending name raises) and again in the client
1260
1262
  interpreter (a hand-built op is warned and skipped). Refused: **event handlers**
1261
- (`on*` → XSS), **URL-bearing** names (`href`, `src`, `srcdoc`, `action`,
1263
+ (`on*` → XSS), **URL-bearing** names (`href`, `src`, `srcset`, `srcdoc`, `action`,
1262
1264
  `formaction`, `xlink:href` → a `javascript:` navigation surface), and **`style`**
1263
1265
  (CSS injection — use classes). The **intended surface** is class ops plus
1264
1266
  `hidden`, `disabled`, `open`, `selected`, and any `aria-*` / `data-*` attribute.
1267
+ - **`copy_media(to, from:)`** (#373) copies `src`, `srcset` and `alt` from the
1268
+ `from` `<img>` onto the `to` `<img>` — how a preview shows an image the server
1269
+ already rendered while `src` stays refused. Both are elements (`:root` or a
1270
+ selector); the URL is read from the live source element, never passed. A
1271
+ missing source or a non-`<img>` source clears the target; an `<img>` source
1272
+ without `src` removes the target's `src`/`srcset` and copies its `alt` (#381),
1273
+ so an entry with no image keeps its name; a non-`<img>` target is warned about and skipped; an attribute that already
1274
+ holds the copied value is not rewritten, so the same image twice does not
1275
+ reload. Allowed in a broadcast (it only reads the subscriber's own page).
1265
1276
  - **`focus(to)`** focuses the first match; **`focus_first(to)`** focuses the first
1266
1277
  focusable descendant of the match (e.g. the first menuitem inside an opened
1267
1278
  menu).
@@ -1301,6 +1312,16 @@ button(**on_client(:click, js
1301
1312
  a plain `requestSubmit()`. Note that a selector target resolves *inside* the
1302
1313
  root, so when the form **is** the reactive root, use the default target:
1303
1314
  `js.submit(submitter: "#delete-submit")`.
1315
+ - **`set_value(to, value)`** fills a field — an `input`, `textarea` or
1316
+ `select` — from text already on the page (issue #374): it assigns the live
1317
+ `.value` (never the attribute, never parsed as HTML; `nil` and `""` clear) and
1318
+ dispatches one bubbling `input` when the value changed, so reducers and
1319
+ `reactive_show` react as if typed. No `change`, no focus (chain `js.focus`).
1320
+ A `select` selects the matching option; a file input accepts `""` only. A
1321
+ `contenteditable` or any other non-field warns and is skipped; `:root`
1322
+ raises. On a checkbox or radio it sets the submitted value, not the checked
1323
+ state (use `check_group` or a click for that). `js.set_attr(…, "value", …)`
1324
+ still writes only the attribute. Actor-only: refused in broadcasts.
1304
1325
  - **`paste_into(to)`** reads the clipboard into a field on a **user gesture**
1305
1326
  (issue #228): `navigator.clipboard.readText()`, then the field gets the text
1306
1327
  through the **normal `input` pipeline** — `.value` is set, a bubbling `input`
@@ -1320,6 +1341,31 @@ button(**on_client(:click, js
1320
1341
  (a broadcast that reads every subscriber's clipboard would be hostile). The
1321
1342
  op is async fire-and-forget — chained siblings apply immediately, never
1322
1343
  waiting for the read.
1344
+ - **`preview_file(to, into:, name: nil, global: false, valid: false)`** (#375; `global: true`
1345
+ resolves `to`, `into:` and `name:` document-wide, for an `img` outside the
1346
+ root) paints the file a file input
1347
+ holds onto an `img` **before any upload**:
1348
+ `input(type: "file", id: "photo-file", accept: "image/*", **on_client(:change,
1349
+ js.preview_file("#photo-file", into: "#photo", name: "#photo-name")))`. On
1350
+ `change` the client takes the input's first `image/*` file, makes an object
1351
+ URL from it (`URL.createObjectURL`), sets it as the `img`'s `src`, and puts
1352
+ `file.name` in the `name:` node's `textContent`. An empty selection or a
1353
+ non-image file removes the `src` and clears the name. The op carries **no
1354
+ URL** — there is no parameter for one and the client never reads a `url` key
1355
+ — so `set_attr` of `src` (and `srcset`, #373) stays refused (#96); only an `<img>` ever gets a
1356
+ `src` (an `into:` that is anything else warns and is skipped, as is a `to`
1357
+ that is not an `input[type=file]`). The runtime keeps the last URL per `img`
1358
+ and revokes it on a re-pick, a clear, and when the `img` leaves the page or a
1359
+ morph strips its `src` — a server re-render resets the preview like any other
1360
+ op. **Actor-only**: refused in `broadcast_to(js:)`.
1361
+ **Upload validation (#382):** `valid: true` treats an input whose
1362
+ `validity.valid` is false (`setCustomValidity`, `required`) as empty, so a
1363
+ rejected file never gets a preview. An app that clears the input itself
1364
+ (`input.value = ""` fires no `change`) dispatches `reactive:file-changed` on
1365
+ it; bind the op to that event too and the preview, the name and the URL are
1366
+ cleared: `op = js.preview_file("#photo-file", into: "#photo", valid: true)`,
1367
+ `input(type: "file", id: "photo-file", **mix(on_client(:change, op),
1368
+ on_client("reactive:file-changed", op)))`.
1323
1369
  - **`expanded:`** on `show`/`hide`/`toggle` (#271) keeps a disclosure trigger's
1324
1370
  `aria-expanded` honest: `js.toggle("#menu", expanded: "#trigger")` writes the
1325
1371
  op's **intended** state (`show` → `"true"`, `hide` → `"false"`, `toggle` → the
@@ -1333,6 +1379,27 @@ button(**on_client(:click, js
1333
1379
  frame, and the helper classes are cleaned up on `animationend` (with a timeout
1334
1380
  fallback, so an element with no animation never leaves them stuck). The op chain
1335
1381
  is never blocked — later ops (a `focus`, a `dispatch`) run immediately.
1382
+ - **`keep: true`** (#369) on `show`/`hide`/`toggle`, the class ops and the attr
1383
+ ops makes the op's result survive a **morph** of its target (a `reply.morph`, a
1384
+ `broadcast_to(…, morph: true)`, a page-refresh morph). Without it the morph
1385
+ writes `class`, `hidden` and every attribute back from the server render, so a
1386
+ selection made on the client is lost the moment another viewer's change
1387
+ morphs the list. The client records what each kept op left on each target —
1388
+ the class on or off, `hidden`, the attribute's value or its absence — and puts
1389
+ it back after Turbo morphs that element. Classes and attributes no kept op
1390
+ touched still follow the render; the latest kept op on an element and a class
1391
+ or attribute wins, so "clear all, then select one" leaves one selected (an op
1392
+ *without* `keep:` leaves the kept record alone, so close a kept panel with a
1393
+ kept `hide`); an
1394
+ element a stream **replaces** is a new element and starts clean. With
1395
+ `expanded:`, the trigger's `aria-expanded` is kept too. Refused in
1396
+ `broadcast_to(js:)`: kept state belongs to one tab.
1397
+
1398
+ ```ruby
1399
+ js.remove_class(".item", "selected", keep: true)
1400
+ .add_class("#item-#{item.id}", "selected", keep: true)
1401
+ .show("#details-#{item.id}", keep: true)
1402
+ ```
1336
1403
 
1337
1404
  `window:`, `once:`, and `outside:` compose exactly like `on(...)`'s event
1338
1405
  modifiers: the dropdown above closes on any click outside the component, and
@@ -2093,7 +2160,10 @@ side effect** — the missing piece that used to force a bespoke controller next
2093
2160
  to an otherwise-declarative compute. Return an op chain (the `ops` builder
2094
2161
  mirrors the Ruby `js` verbs — minus `paste_into`, deliberately: a reducer runs
2095
2162
  on every input event, and a clipboard read per keystroke would spam permission
2096
- prompts — or use a raw `[[op, args], …]` array) and the
2163
+ prompts; `ops.set_value(selector, value)` is included, but its `input` re-runs
2164
+ the reducer, so a value derived from the field it writes changes the chain on
2165
+ every pass and fires again each time — return a field the reducer owns as an
2166
+ output instead — or use a raw `[[op, args], …]` array) and the
2097
2167
  controller runs it through the **same frozen op whitelist** `on_client` uses,
2098
2168
  as a final phase **after** the field writes, text sinks, and their dispatched
2099
2169
  `input` events settle. The canonical one-time-code field:
@@ -2703,7 +2773,7 @@ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams
2703
2773
  | Builder | Reply |
2704
2774
  |---|---|
2705
2775
  | `reply.replace` / `reply.update(morph: false)` | re-render in place (default; `replace` swaps the whole element via outerHTML, `update` swaps only the inner HTML) |
2706
- | `reply.morph` / `reply.replace(morph: true)` / `reply.update(morph: true)` | re-render in place via Idiomorph (`method="morph"`) — preserves the focused `<input>`/`<textarea>`, its caret AND what was typed in it (the render's value becomes its new default; other fields take the server's value; `data-reactive-morph-value` on a field lets the morph overwrite it while focused, #338); for per-field reactive editing (`replace` #28; `update` #113) |
2776
+ | `reply.morph` / `reply.replace(morph: true)` / `reply.update(morph: true)` | re-render in place via Idiomorph (`method="morph"`) — preserves the focused `<input>`/`<textarea>`, its caret AND what was typed in it (the render's value becomes its new default; other fields take the server's value; `data-reactive-morph-value` on a field lets the morph overwrite it while focused, #338; a held field whose render differs from its default is marked `data-reactive-held` + `reactive:held`, and the root's next action sees it in `reactive_held_fields` when its `params:` schema declares that field, #370); for per-field reactive editing (`replace` #28; `update` #113) |
2707
2777
  | `.also(target => content, …)` | **UPDATE** (inner HTML) companion elements by DOM id; `content` is a plain string (escaped) or a Phlex component. The argument *type* picks the action — pairs mean `update` |
2708
2778
  | `.also(component, morph: false)` | **REPLACE** another Streamable component at its own `#id` (`morph: true` morphs in place). A component argument means `replace` |
2709
2779
  | `.flash(level, content, target: …)` | append a flash; `content` is a plain string (escaped, wrapped in a level-carrying `<div>` — see [Flash levels](#flash-levels)) or a Phlex component (rendered verbatim; off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
@@ -3065,11 +3135,12 @@ Notifications::Badge.broadcast_to(user, :alerts,
3065
3135
  ```
3066
3136
 
3067
3137
  `broadcast_to` with `js:` **refuses the actor-only ops** (`focus`/`focus_first`/
3068
- `show_modal`/`close_dialog`/`submit`/`paste_into`/`persist_state`/`persist_clear`/`check_group` raise
3138
+ `show_modal`/`close_dialog`/`submit`/`paste_into`/`preview_file`/`persist_state`/`persist_clear`/`check_group`/`set_value` raise
3069
3139
  `ArgumentError`): broadcasting focus (or a modal) would steal it in every subscriber's tab, a
3070
3140
  broadcast submit would force-submit every subscriber's form, and a broadcast
3071
- clipboard read, draft write or selection change would be hostile — these
3072
- belong to the actor's own reply or gesture. Everything else is a fair broadcast
3141
+ clipboard read, draft write, selection change or field write would be hostile — these
3142
+ belong to the actor's own reply or gesture. It refuses `keep: true` on any op
3143
+ too (#369): kept state belongs to the tab that ran the op. Everything else is a fair broadcast
3073
3144
  (class and attribute toggles, `text`, `dispatch`). As with `on_client`, the ops are
3074
3145
  whitelist-interpreted client-side — an unknown op warns and is skipped — and the
3075
3146
  ops attribute is HTML-escaped, so a value can't break out of it. `reactive:js`
@@ -3077,8 +3148,10 @@ is not a self-render: it never counts toward the token refresh, so the reply's
3077
3148
  signed token still rolls forward exactly as it would without the ops.
3078
3149
 
3079
3150
  > **Ephemeral by design.** Like `on_client`, server-pushed ops are transient UI:
3080
- > the next server re-render of the component resets whatever they toggled. State
3081
- > that must survive a re-render belongs in a signed `action`, not an op.
3151
+ > the next server re-render of the component resets whatever they toggled. The
3152
+ > exception is an op with `keep: true` (#369), whose result survives a **morph**
3153
+ > of its target in the tab that ran it. State that must survive a replace, or
3154
+ > that the server must know, belongs in a signed `action`, not an op.
3082
3155
 
3083
3156
  #### Record-authorized, transient-state actions (issue #64)
3084
3157
 
@@ -350,7 +350,10 @@ module Phlex
350
350
  # Issue #358: a presence root also sends its presence connection, the
351
351
  # row a presence write lands on.
352
352
  Phlex::Reactive::Presence.with_connection_id(request.headers[Phlex::Reactive::Presence::HEADER]) do
353
- run_action_wrapped(component, action_def, coerced)
353
+ # Issue #370: the fields a morph held over a changed server value,
354
+ # filtered to this action's declared params (reactive_held_fields).
355
+ held = Phlex::Reactive::HeldFields.parse(request.headers[Phlex::Reactive::HeldFields::HEADER], action_def)
356
+ Phlex::Reactive::HeldFields.with(held) { run_action_wrapped(component, action_def, coerced) }
354
357
  end
355
358
  end
356
359
  end
@@ -152,10 +152,14 @@ export function __resetComputeRegistryForTest() {
152
152
  // wire.
153
153
  const ROOT_SENTINEL = "@root"
154
154
 
155
- function targetArgs(to, { global, transition } = {}) {
155
+ // `keep: true` (issue #369) on show/hide/toggle and the class and attr ops:
156
+ // a morph of the target re-applies the op's result (see Phlex::Reactive::JS).
157
+ // Ignored by the other ops, as the interpreter only keeps those nine.
158
+ function targetArgs(to, { global, transition, keep } = {}) {
156
159
  const args = { to: to ?? ROOT_SENTINEL }
157
160
  if (global) args.global = true
158
161
  if (transition) args.transition = normalizeTransition(transition)
162
+ if (keep) args.keep = true
159
163
  return args
160
164
  }
161
165
 
@@ -225,6 +229,15 @@ class OpsChain {
225
229
  return this.#append("text", { ...targetArgs(to, opts), value: String(value ?? "") })
226
230
  }
227
231
 
232
+ // A field's live value (issue #374). A field selector is required: the root
233
+ // is not a field. For a field the reducer owns, return it as an output
234
+ // instead; a set_value whose value derives from the field it writes changes
235
+ // the chain on every pass, so it fires again on every pass.
236
+ set_value(to, value, opts) {
237
+ if (to == null || to === ROOT_SENTINEL) throw new Error("[phlex-reactive] ops.set_value targets a field — pass its CSS selector")
238
+ return this.#append("set_value", { ...targetArgs(to, opts), value: String(value ?? "") })
239
+ }
240
+
228
241
  dispatch(name, { to, detail, global } = {}) {
229
242
  const args = { name: String(name), to: to ?? ROOT_SENTINEL, detail: detail ?? {} }
230
243
  if (global) args.global = true
@@ -1,4 +1,4 @@
1
- var i=new Map;function l(t,e){i.set(t,e)}function f(t){return i.get(t)}function h(){i.clear()}var a="@root";function s(t,{global:e,transition:r}={}){let n={to:t??a};if(e)n.global=!0;if(r)n.transition=g(r);return n}function g(t){if(!(t&&typeof t==="object"&&!Array.isArray(t)&&["during","from","to"].every((r)=>(r in t))))throw Error("[phlex-reactive] ops transition takes named legs { during, from, to }");return Object.freeze([String(t.during),String(t.from),String(t.to)])}class u{constructor(t=Object.freeze([])){this.ops=t,Object.freeze(this)}show(t,e){return this.#t("show",s(t,e))}hide(t,e){return this.#t("hide",s(t,e))}toggle(t,e){return this.#t("toggle",s(t,e))}add_class(t,e,r){return this.#t("add_class",o(t,e,r))}remove_class(t,e,r){return this.#t("remove_class",o(t,e,r))}toggle_class(t,e,r){return this.#t("toggle_class",o(t,e,r))}set_attr(t,e,r,n){return this.#t("set_attr",{...s(t,n),name:String(e),value:String(r)})}remove_attr(t,e,r){return this.#t("remove_attr",{...s(t,r),name:String(e)})}toggle_attr(t,e,r){return this.#t("toggle_attr",{...s(t,r),name:String(e)})}focus(t,e){return this.#t("focus",s(t,e))}focus_first(t,e){return this.#t("focus_first",s(t,e))}text(t,e,r){return this.#t("text",{...s(t,r),value:String(e??"")})}dispatch(t,{to:e,detail:r,global:n}={}){let c={name:String(t),to:e??a,detail:r??{}};if(n)c.global=!0;return this.#t("dispatch",c)}submit(t,e){return this.#t("submit",s(t,e))}toJSON(){return this.ops}#t(t,e){return new u(Object.freeze([...this.ops,Object.freeze([t,Object.freeze(e)])]))}}function o(t,e,r){let n=e==null?[]:(Array.isArray(e)?e:[e]).map(String);if(n.length===0)throw Error("[phlex-reactive] a class op needs at least one class");return{...s(t,r),classes:Object.freeze(n)}}var m=new u;export{h as __resetComputeRegistryForTest,f as computeReducer,m as ops,l as setComputeReducer};
1
+ var a=new Map;function g(e,t){a.set(e,t)}function f(e){return a.get(e)}function h(){a.clear()}var u="@root";function s(e,{global:t,transition:r,keep:n}={}){let o={to:e??u};if(t)o.global=!0;if(r)o.transition=l(r);if(n)o.keep=!0;return o}function l(e){if(!(e&&typeof e==="object"&&!Array.isArray(e)&&["during","from","to"].every((r)=>(r in e))))throw Error("[phlex-reactive] ops transition takes named legs { during, from, to }");return Object.freeze([String(e.during),String(e.from),String(e.to)])}class c{constructor(e=Object.freeze([])){this.ops=e,Object.freeze(this)}show(e,t){return this.#e("show",s(e,t))}hide(e,t){return this.#e("hide",s(e,t))}toggle(e,t){return this.#e("toggle",s(e,t))}add_class(e,t,r){return this.#e("add_class",i(e,t,r))}remove_class(e,t,r){return this.#e("remove_class",i(e,t,r))}toggle_class(e,t,r){return this.#e("toggle_class",i(e,t,r))}set_attr(e,t,r,n){return this.#e("set_attr",{...s(e,n),name:String(t),value:String(r)})}remove_attr(e,t,r){return this.#e("remove_attr",{...s(e,r),name:String(t)})}toggle_attr(e,t,r){return this.#e("toggle_attr",{...s(e,r),name:String(t)})}focus(e,t){return this.#e("focus",s(e,t))}focus_first(e,t){return this.#e("focus_first",s(e,t))}text(e,t,r){return this.#e("text",{...s(e,r),value:String(t??"")})}set_value(e,t,r){if(e==null||e===u)throw Error("[phlex-reactive] ops.set_value targets a field — pass its CSS selector");return this.#e("set_value",{...s(e,r),value:String(t??"")})}dispatch(e,{to:t,detail:r,global:n}={}){let o={name:String(e),to:t??u,detail:r??{}};if(n)o.global=!0;return this.#e("dispatch",o)}submit(e,t){return this.#e("submit",s(e,t))}toJSON(){return this.ops}#e(e,t){return new c(Object.freeze([...this.ops,Object.freeze([e,Object.freeze(t)])]))}}function i(e,t,r){let n=t==null?[]:(Array.isArray(t)?t:[t]).map(String);if(n.length===0)throw Error("[phlex-reactive] a class op needs at least one class");return{...s(e,r),classes:Object.freeze(n)}}var p=new c;export{h as __resetComputeRegistryForTest,f as computeReducer,p as ops,g as setComputeReducer};
2
2
 
3
- //# debugId=58A4BBBA7E66031D64756E2164756E21
3
+ //# debugId=17744106F7A5916F64756E2164756E21
4
4
  //# sourceMappingURL=compute.min.js.map
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["compute.js"],
4
4
  "sourcesContent": [
5
- "// The client-side compute (data-binding) registry — the \"instant\" half of the\n// new/unpersisted-record UX.\n//\n// A record-backed reactive component round-trips every change to the server\n// (the signed identity re-finds the record; the server re-renders). A NEW,\n// unpersisted record has no such server truth to re-render against on every\n// keystroke — the classic answer is a bespoke Stimulus controller doing the math\n// in the browser (carlqvist's new_order_controller.js). This registry lets that\n// math be a DECLARED part of the component instead: `reactive_compute :name,\n// inputs:, outputs:` (Ruby) names a reducer registered here, and the generic\n// reactive controller runs it on `input` — writing the outputs with NO round\n// trip. When the component ALSO carries on(...) (a persisted record, or a draft\n// you sync), the debounced POST reconciles from the authoritative server reply.\n//\n// The seam mirrors confirm.js: a settable registry with a lookup the controller\n// calls. Register once at boot:\n//\n// import { setComputeReducer } from \"phlex/reactive/compute\"\n// setComputeReducer(\"payment_split\", ({ allowance, cash, leasing, total }) => ({\n// allowance, leasing, cash: total - allowance - leasing,\n// }))\n//\n// The reducer's signature is (values, meta):\n//\n// values — a plain object of { inputName: value } over the declared inputs.\n// Untyped inputs (the array form) AND :number-typed inputs arrive as\n// Numbers (blank/NaN → 0); :string-typed inputs (the hash form,\n// issue #104) arrive as the RAW string. `reactive_compute :x, inputs:\n// { title: :string, qty: :number }` is what selects per-input types;\n// `inputs: %i[a b]` stays all-numeric (backward compatible).\n// A CHECKBOX arrives as its checked state, never its constant\n// .value (issue #262): 1/0 untyped or :number, \"true\"/\"false\" as a\n// :string, true/false as a :boolean. A radio group arrives as its\n// checked radio's value (\"\" / 0 when none is checked).\n// meta — { changed }: the name (string) of the declared input the\n// triggering event edited, or null (a direct recompute() call, or a\n// target this root doesn't own / didn't declare as an input).\n//\n// OUTPUTS may be a form FIELD or a TEXT NODE (issue #104). An output whose name\n// matches an owned control writes its .value (+ the change-guarded input\n// dispatch below) — or, for a checkbox or a radio group, its CHECKED state\n// (issue #262): a truthy result ticks the box (\"\", \"0\", \"false\", 0 and false\n// untick it), and a radio group checks the radio carrying the result. Their\n// value attributes are never rewritten, so what they submit is unchanged.\n// An output with NO matching field writes textContent to every\n// owned [data-reactive-text=\"<name>\"] node (reactive_text(:name)) — XSS-safe by\n// construction, change-guarded, NO input dispatch (a text node has no listener\n// contract). A declared INPUT also mirrors into its own text node on every\n// input via an always-run pass — so reactive_text(:title) is a live field\n// preview with NO registered reducer at all.\n//\n// It returns a plain object of { outputName: value } — only the outputs it\n// names are written, so it can leave the edited field (and its caret)\n// untouched. A one-argument reducer keeps working unchanged (it just ignores\n// meta). `changed` is what makes a MULTI-WAY / MUTUAL rebalance expressible as\n// one reducer (issue #75) — branch on which field the user edited:\n//\n// setComputeReducer(\"three_way_split\", ({ field_a, field_b, field_c, total }, { changed }) => {\n// if (changed === \"field_c\") return { field_a: total - field_c - field_b }\n// return { field_c: total - field_a - field_b }\n// })\n//\n// Output writes are CHANGE-GUARDED: the controller writes a field and\n// dispatches a bubbling `input` event on it ONLY when the new value differs\n// from the field's current value (real browsers never fire `input` on a\n// programmatic .value write, so the controller dispatches explicitly — that's\n// what drives a chained summary repaint, matching the server's set_value +\n// dispatch(\"input\") contract). Returning the SAME value a field already holds\n// is skipped entirely — no write, no event — which is why a reducer with\n// overlapping inputs/outputs (like payment_split above) settles instead of\n// re-entering itself forever.\n//\n// CONVERGENCE REQUIREMENT: because an output write dispatches a REAL input\n// event (issue #76), recompute re-enters with changed = that OUTPUT field's\n// name (when it's also a declared input). A branching reducer must therefore\n// be convergent: the re-entrant pass must compute values EQUAL to what the\n// first pass already wrote to the DOM, so the change guard settles the chain.\n// The three_way_split above is: after `changed === \"field_a\"` writes field_c,\n// the re-entrant `changed === \"field_c\"` pass derives field_a back to the\n// value it already holds — no write, no event, settled in one bounce.\n\n// THE RESERVED `$ops` OUTPUT (issue #226). Besides field/text outputs, a\n// reducer may return the reserved key `$ops` holding a chain of client DOM\n// ops — built with the `ops` builder below, or a raw [[name, args], ...]\n// array. The controller consumes it as a PHASE 4 of the single-pass write set:\n// the ops run AFTER the field writes, text sinks, and phase-3 input dispatches\n// settle, through the SAME frozen CLIENT_OPS whitelist on_client uses (an\n// unknown op warns + is skipped). `null`/`undefined`/absent = no effect.\n//\n// RISING-EDGE semantics, keyed on CONTENT: the chain runs only when it\n// DIFFERS from the previous pass's chain (including from \"absent\"), and only\n// on EVENT-DRIVEN passes. Returning the SAME chain again is settled — no\n// re-fire (a 7th keystroke capped back to the same complete value can't\n// re-submit), mirroring the change-guarded field writes. Returning a\n// DIFFERENT chain fires again — a multi-box reducer advancing focus\n// box-by-box emits a new focus target per digit, each a new intent. The\n// connect/morph SEED pass (issue #199 — recompute with no event) ARMS the\n// latch but never fires — a form re-rendered with an already-complete value\n// (a validation-error morph, a browser restore) must not auto-fire; that is\n// what breaks the submit → error re-render → re-seed → submit loop. A later\n// pass returning no $ops re-arms. The canonical use — a one-time-code field\n// that normalizes on input and commits when complete:\n//\n// import { setComputeReducer, ops } from \"phlex/reactive/compute\"\n// setComputeReducer(\"otp\", ({ code }) => {\n// const digits = code.replace(/\\D/g, \"\").slice(0, 6)\n// return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }\n// })\n//\n// `submit` commits the target's own form via requestSubmit() — the real submit\n// event fires, so an on(:verify, event: \"submit\") interception (or a native/\n// Turbo form) handles it exactly like a user submit. submit/focus are\n// ACTOR-ONLY: usable here and in on_client/reply.js, refused in broadcasts.\n// paste_into (issue #228) is actor-only too but DELIBERATELY absent from this\n// builder: a reducer runs on every input event, and a clipboard read per\n// keystroke (each changed chain fires) would spam permission prompts. Use it\n// from on_client / reply.js / reactive_on_complete instead; a raw\n// [[\"paste_into\", …]] pair still interprets if you truly need it.\n\nconst reducers = new Map()\n\n// Register (or replace) the reducer for `key`. `fn` is\n// (values: Record<string, number>, meta: { changed: string | null })\n// => Record<string, unknown>.\nexport function setComputeReducer(key, fn) {\n reducers.set(key, fn)\n}\n\n// Look up a registered reducer; undefined when none — the controller then makes\n// #recompute a no-op rather than throwing (a missing reducer must not break the\n// page; it just means no client-side binding for that root).\nexport function computeReducer(key) {\n return reducers.get(key)\n}\n\n// Test seam: clear the registry so a reducer registered in one test can't leak.\nexport function __resetComputeRegistryForTest() {\n reducers.clear()\n}\n\n// --- The reducer-side op-chain builder (issue #226) --------------------------\n//\n// A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder (minus\n// paste_into — see the actor-only note above): verbs carry\n// the WIRE op names (snake_case) and append [name, args] pairs; every verb\n// returns a NEW instance, so a chain held in a constant can never be mutated by\n// later use. `.ops` exposes the raw [[name, args], ...] list the controller\n// interprets (and toJSON serializes it, so a chain can also feed a hand-built\n// ops attr). Targets: a CSS selector string, or omit for \"@root\" (the\n// component's own root). No build-time attr validation here — the interpreter's\n// allowlist (guardAttr) is the enforcement point; this builder only shapes the\n// wire.\nconst ROOT_SENTINEL = \"@root\"\n\nfunction targetArgs(to, { global, transition } = {}) {\n const args = { to: to ?? ROOT_SENTINEL }\n if (global) args.global = true\n if (transition) args.transition = normalizeTransition(transition)\n return args\n}\n\n// Named legs { during, from, to } → the [during, from, to] wire array (the\n// issue #186 vocabulary). Loud at authoring time, like the Ruby builder.\n// Frozen, like every nested payload — the chain's immutability contract must\n// hold all the way down (the Ruby twin freezes its legs array too).\nfunction normalizeTransition(transition) {\n const named =\n transition && typeof transition === \"object\" && !Array.isArray(transition) &&\n [\"during\", \"from\", \"to\"].every((k) => k in transition)\n if (!named) throw new Error(\"[phlex-reactive] ops transition takes named legs { during, from, to }\")\n return Object.freeze([String(transition.during), String(transition.from), String(transition.to)])\n}\n\nclass OpsChain {\n constructor(list = Object.freeze([])) {\n this.ops = list\n Object.freeze(this)\n }\n\n show(to, opts) {\n return this.#append(\"show\", targetArgs(to, opts))\n }\n\n hide(to, opts) {\n return this.#append(\"hide\", targetArgs(to, opts))\n }\n\n toggle(to, opts) {\n return this.#append(\"toggle\", targetArgs(to, opts))\n }\n\n add_class(to, classes, opts) {\n return this.#append(\"add_class\", classArgs(to, classes, opts))\n }\n\n remove_class(to, classes, opts) {\n return this.#append(\"remove_class\", classArgs(to, classes, opts))\n }\n\n toggle_class(to, classes, opts) {\n return this.#append(\"toggle_class\", classArgs(to, classes, opts))\n }\n\n set_attr(to, name, value, opts) {\n return this.#append(\"set_attr\", { ...targetArgs(to, opts), name: String(name), value: String(value) })\n }\n\n remove_attr(to, name, opts) {\n return this.#append(\"remove_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n toggle_attr(to, name, opts) {\n return this.#append(\"toggle_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n focus(to, opts) {\n return this.#append(\"focus\", targetArgs(to, opts))\n }\n\n focus_first(to, opts) {\n return this.#append(\"focus_first\", targetArgs(to, opts))\n }\n\n text(to, value, opts) {\n return this.#append(\"text\", { ...targetArgs(to, opts), value: String(value ?? \"\") })\n }\n\n dispatch(name, { to, detail, global } = {}) {\n const args = { name: String(name), to: to ?? ROOT_SENTINEL, detail: detail ?? {} }\n if (global) args.global = true\n return this.#append(\"dispatch\", args)\n }\n\n submit(to, opts) {\n return this.#append(\"submit\", targetArgs(to, opts))\n }\n\n toJSON() {\n return this.ops\n }\n\n #append(name, args) {\n return new OpsChain(Object.freeze([...this.ops, Object.freeze([name, Object.freeze(args)])]))\n }\n}\n\n// classes: one class string or an array of them (never whitespace-split — a\n// classList token can't contain spaces, so splitting would only mask a bug).\n// Loud on an empty/missing list, and frozen (the Ruby twin freezes its class\n// list too) — a chain held in a constant must stay immutable all the way down.\nfunction classArgs(to, classes, opts) {\n const list = classes == null ? [] : (Array.isArray(classes) ? classes : [classes]).map(String)\n if (list.length === 0) throw new Error(\"[phlex-reactive] a class op needs at least one class\")\n return { ...targetArgs(to, opts), classes: Object.freeze(list) }\n}\n\n// The shared empty chain — start every reducer effect from here:\n// $ops: done ? ops.dispatch(\"code:complete\").submit() : null\nexport const ops = new OpsChain()\n"
5
+ "// The client-side compute (data-binding) registry — the \"instant\" half of the\n// new/unpersisted-record UX.\n//\n// A record-backed reactive component round-trips every change to the server\n// (the signed identity re-finds the record; the server re-renders). A NEW,\n// unpersisted record has no such server truth to re-render against on every\n// keystroke — the classic answer is a bespoke Stimulus controller doing the math\n// in the browser (carlqvist's new_order_controller.js). This registry lets that\n// math be a DECLARED part of the component instead: `reactive_compute :name,\n// inputs:, outputs:` (Ruby) names a reducer registered here, and the generic\n// reactive controller runs it on `input` — writing the outputs with NO round\n// trip. When the component ALSO carries on(...) (a persisted record, or a draft\n// you sync), the debounced POST reconciles from the authoritative server reply.\n//\n// The seam mirrors confirm.js: a settable registry with a lookup the controller\n// calls. Register once at boot:\n//\n// import { setComputeReducer } from \"phlex/reactive/compute\"\n// setComputeReducer(\"payment_split\", ({ allowance, cash, leasing, total }) => ({\n// allowance, leasing, cash: total - allowance - leasing,\n// }))\n//\n// The reducer's signature is (values, meta):\n//\n// values — a plain object of { inputName: value } over the declared inputs.\n// Untyped inputs (the array form) AND :number-typed inputs arrive as\n// Numbers (blank/NaN → 0); :string-typed inputs (the hash form,\n// issue #104) arrive as the RAW string. `reactive_compute :x, inputs:\n// { title: :string, qty: :number }` is what selects per-input types;\n// `inputs: %i[a b]` stays all-numeric (backward compatible).\n// A CHECKBOX arrives as its checked state, never its constant\n// .value (issue #262): 1/0 untyped or :number, \"true\"/\"false\" as a\n// :string, true/false as a :boolean. A radio group arrives as its\n// checked radio's value (\"\" / 0 when none is checked).\n// meta — { changed }: the name (string) of the declared input the\n// triggering event edited, or null (a direct recompute() call, or a\n// target this root doesn't own / didn't declare as an input).\n//\n// OUTPUTS may be a form FIELD or a TEXT NODE (issue #104). An output whose name\n// matches an owned control writes its .value (+ the change-guarded input\n// dispatch below) — or, for a checkbox or a radio group, its CHECKED state\n// (issue #262): a truthy result ticks the box (\"\", \"0\", \"false\", 0 and false\n// untick it), and a radio group checks the radio carrying the result. Their\n// value attributes are never rewritten, so what they submit is unchanged.\n// An output with NO matching field writes textContent to every\n// owned [data-reactive-text=\"<name>\"] node (reactive_text(:name)) — XSS-safe by\n// construction, change-guarded, NO input dispatch (a text node has no listener\n// contract). A declared INPUT also mirrors into its own text node on every\n// input via an always-run pass — so reactive_text(:title) is a live field\n// preview with NO registered reducer at all.\n//\n// It returns a plain object of { outputName: value } — only the outputs it\n// names are written, so it can leave the edited field (and its caret)\n// untouched. A one-argument reducer keeps working unchanged (it just ignores\n// meta). `changed` is what makes a MULTI-WAY / MUTUAL rebalance expressible as\n// one reducer (issue #75) — branch on which field the user edited:\n//\n// setComputeReducer(\"three_way_split\", ({ field_a, field_b, field_c, total }, { changed }) => {\n// if (changed === \"field_c\") return { field_a: total - field_c - field_b }\n// return { field_c: total - field_a - field_b }\n// })\n//\n// Output writes are CHANGE-GUARDED: the controller writes a field and\n// dispatches a bubbling `input` event on it ONLY when the new value differs\n// from the field's current value (real browsers never fire `input` on a\n// programmatic .value write, so the controller dispatches explicitly — that's\n// what drives a chained summary repaint, matching the server's set_value +\n// dispatch(\"input\") contract). Returning the SAME value a field already holds\n// is skipped entirely — no write, no event — which is why a reducer with\n// overlapping inputs/outputs (like payment_split above) settles instead of\n// re-entering itself forever.\n//\n// CONVERGENCE REQUIREMENT: because an output write dispatches a REAL input\n// event (issue #76), recompute re-enters with changed = that OUTPUT field's\n// name (when it's also a declared input). A branching reducer must therefore\n// be convergent: the re-entrant pass must compute values EQUAL to what the\n// first pass already wrote to the DOM, so the change guard settles the chain.\n// The three_way_split above is: after `changed === \"field_a\"` writes field_c,\n// the re-entrant `changed === \"field_c\"` pass derives field_a back to the\n// value it already holds — no write, no event, settled in one bounce.\n\n// THE RESERVED `$ops` OUTPUT (issue #226). Besides field/text outputs, a\n// reducer may return the reserved key `$ops` holding a chain of client DOM\n// ops — built with the `ops` builder below, or a raw [[name, args], ...]\n// array. The controller consumes it as a PHASE 4 of the single-pass write set:\n// the ops run AFTER the field writes, text sinks, and phase-3 input dispatches\n// settle, through the SAME frozen CLIENT_OPS whitelist on_client uses (an\n// unknown op warns + is skipped). `null`/`undefined`/absent = no effect.\n//\n// RISING-EDGE semantics, keyed on CONTENT: the chain runs only when it\n// DIFFERS from the previous pass's chain (including from \"absent\"), and only\n// on EVENT-DRIVEN passes. Returning the SAME chain again is settled — no\n// re-fire (a 7th keystroke capped back to the same complete value can't\n// re-submit), mirroring the change-guarded field writes. Returning a\n// DIFFERENT chain fires again — a multi-box reducer advancing focus\n// box-by-box emits a new focus target per digit, each a new intent. The\n// connect/morph SEED pass (issue #199 — recompute with no event) ARMS the\n// latch but never fires — a form re-rendered with an already-complete value\n// (a validation-error morph, a browser restore) must not auto-fire; that is\n// what breaks the submit → error re-render → re-seed → submit loop. A later\n// pass returning no $ops re-arms. The canonical use — a one-time-code field\n// that normalizes on input and commits when complete:\n//\n// import { setComputeReducer, ops } from \"phlex/reactive/compute\"\n// setComputeReducer(\"otp\", ({ code }) => {\n// const digits = code.replace(/\\D/g, \"\").slice(0, 6)\n// return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }\n// })\n//\n// `submit` commits the target's own form via requestSubmit() — the real submit\n// event fires, so an on(:verify, event: \"submit\") interception (or a native/\n// Turbo form) handles it exactly like a user submit. submit/focus are\n// ACTOR-ONLY: usable here and in on_client/reply.js, refused in broadcasts.\n// paste_into (issue #228) is actor-only too but DELIBERATELY absent from this\n// builder: a reducer runs on every input event, and a clipboard read per\n// keystroke (each changed chain fires) would spam permission prompts. Use it\n// from on_client / reply.js / reactive_on_complete instead; a raw\n// [[\"paste_into\", …]] pair still interprets if you truly need it.\n\nconst reducers = new Map()\n\n// Register (or replace) the reducer for `key`. `fn` is\n// (values: Record<string, number>, meta: { changed: string | null })\n// => Record<string, unknown>.\nexport function setComputeReducer(key, fn) {\n reducers.set(key, fn)\n}\n\n// Look up a registered reducer; undefined when none — the controller then makes\n// #recompute a no-op rather than throwing (a missing reducer must not break the\n// page; it just means no client-side binding for that root).\nexport function computeReducer(key) {\n return reducers.get(key)\n}\n\n// Test seam: clear the registry so a reducer registered in one test can't leak.\nexport function __resetComputeRegistryForTest() {\n reducers.clear()\n}\n\n// --- The reducer-side op-chain builder (issue #226) --------------------------\n//\n// A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder (minus\n// paste_into — see the actor-only note above): verbs carry\n// the WIRE op names (snake_case) and append [name, args] pairs; every verb\n// returns a NEW instance, so a chain held in a constant can never be mutated by\n// later use. `.ops` exposes the raw [[name, args], ...] list the controller\n// interprets (and toJSON serializes it, so a chain can also feed a hand-built\n// ops attr). Targets: a CSS selector string, or omit for \"@root\" (the\n// component's own root). No build-time attr validation here — the interpreter's\n// allowlist (guardAttr) is the enforcement point; this builder only shapes the\n// wire.\nconst ROOT_SENTINEL = \"@root\"\n\n// `keep: true` (issue #369) on show/hide/toggle and the class and attr ops:\n// a morph of the target re-applies the op's result (see Phlex::Reactive::JS).\n// Ignored by the other ops, as the interpreter only keeps those nine.\nfunction targetArgs(to, { global, transition, keep } = {}) {\n const args = { to: to ?? ROOT_SENTINEL }\n if (global) args.global = true\n if (transition) args.transition = normalizeTransition(transition)\n if (keep) args.keep = true\n return args\n}\n\n// Named legs { during, from, to } → the [during, from, to] wire array (the\n// issue #186 vocabulary). Loud at authoring time, like the Ruby builder.\n// Frozen, like every nested payload — the chain's immutability contract must\n// hold all the way down (the Ruby twin freezes its legs array too).\nfunction normalizeTransition(transition) {\n const named =\n transition && typeof transition === \"object\" && !Array.isArray(transition) &&\n [\"during\", \"from\", \"to\"].every((k) => k in transition)\n if (!named) throw new Error(\"[phlex-reactive] ops transition takes named legs { during, from, to }\")\n return Object.freeze([String(transition.during), String(transition.from), String(transition.to)])\n}\n\nclass OpsChain {\n constructor(list = Object.freeze([])) {\n this.ops = list\n Object.freeze(this)\n }\n\n show(to, opts) {\n return this.#append(\"show\", targetArgs(to, opts))\n }\n\n hide(to, opts) {\n return this.#append(\"hide\", targetArgs(to, opts))\n }\n\n toggle(to, opts) {\n return this.#append(\"toggle\", targetArgs(to, opts))\n }\n\n add_class(to, classes, opts) {\n return this.#append(\"add_class\", classArgs(to, classes, opts))\n }\n\n remove_class(to, classes, opts) {\n return this.#append(\"remove_class\", classArgs(to, classes, opts))\n }\n\n toggle_class(to, classes, opts) {\n return this.#append(\"toggle_class\", classArgs(to, classes, opts))\n }\n\n set_attr(to, name, value, opts) {\n return this.#append(\"set_attr\", { ...targetArgs(to, opts), name: String(name), value: String(value) })\n }\n\n remove_attr(to, name, opts) {\n return this.#append(\"remove_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n toggle_attr(to, name, opts) {\n return this.#append(\"toggle_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n focus(to, opts) {\n return this.#append(\"focus\", targetArgs(to, opts))\n }\n\n focus_first(to, opts) {\n return this.#append(\"focus_first\", targetArgs(to, opts))\n }\n\n text(to, value, opts) {\n return this.#append(\"text\", { ...targetArgs(to, opts), value: String(value ?? \"\") })\n }\n\n // A field's live value (issue #374). A field selector is required: the root\n // is not a field. For a field the reducer owns, return it as an output\n // instead; a set_value whose value derives from the field it writes changes\n // the chain on every pass, so it fires again on every pass.\n set_value(to, value, opts) {\n if (to == null || to === ROOT_SENTINEL) throw new Error(\"[phlex-reactive] ops.set_value targets a field — pass its CSS selector\")\n return this.#append(\"set_value\", { ...targetArgs(to, opts), value: String(value ?? \"\") })\n }\n\n dispatch(name, { to, detail, global } = {}) {\n const args = { name: String(name), to: to ?? ROOT_SENTINEL, detail: detail ?? {} }\n if (global) args.global = true\n return this.#append(\"dispatch\", args)\n }\n\n submit(to, opts) {\n return this.#append(\"submit\", targetArgs(to, opts))\n }\n\n toJSON() {\n return this.ops\n }\n\n #append(name, args) {\n return new OpsChain(Object.freeze([...this.ops, Object.freeze([name, Object.freeze(args)])]))\n }\n}\n\n// classes: one class string or an array of them (never whitespace-split — a\n// classList token can't contain spaces, so splitting would only mask a bug).\n// Loud on an empty/missing list, and frozen (the Ruby twin freezes its class\n// list too) — a chain held in a constant must stay immutable all the way down.\nfunction classArgs(to, classes, opts) {\n const list = classes == null ? [] : (Array.isArray(classes) ? classes : [classes]).map(String)\n if (list.length === 0) throw new Error(\"[phlex-reactive] a class op needs at least one class\")\n return { ...targetArgs(to, opts), classes: Object.freeze(list) }\n}\n\n// The shared empty chain — start every reducer effect from here:\n// $ops: done ? ops.dispatch(\"code:complete\").submit() : null\nexport const ops = new OpsChain()\n"
6
6
  ],
7
- "mappings": "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",
8
- "debugId": "58A4BBBA7E66031D64756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "17744106F7A5916F64756E2164756E21",
9
9
  "names": []
10
10
  }