phlex-reactive 0.12.6 → 0.13.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 +4 -4
- data/CHANGELOG.md +127 -2
- data/README.md +268 -16
- data/app/controllers/phlex/reactive/actions_controller.rb +15 -1
- data/app/javascript/phlex/reactive/compute.min.js +2 -2
- data/app/javascript/phlex/reactive/compute.min.js.map +1 -1
- data/app/javascript/phlex/reactive/confirm.min.js +2 -2
- data/app/javascript/phlex/reactive/confirm.min.js.map +1 -1
- data/app/javascript/phlex/reactive/confirm_predicate.min.js +2 -2
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +1 -1
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +1 -1
- data/app/javascript/phlex/reactive/reactive_controller.js +139 -18
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/collections.rb +144 -0
- data/lib/phlex/reactive/component/helpers.rb +5 -0
- data/lib/phlex/reactive/pending.rb +337 -0
- data/lib/phlex/reactive/reply.rb +37 -0
- data/lib/phlex/reactive/response.rb +109 -68
- data/lib/phlex/reactive/settle.rb +170 -0
- data/lib/phlex/reactive/settles.rb +272 -0
- data/lib/phlex/reactive/streamable.rb +146 -3
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +35 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '049bef601e70264a857668de2ad7766f9b4edae020ded0844d2f5efacdc0607d'
|
|
4
|
+
data.tar.gz: 1293bfd5f14ef03fa1889444c662846d4f1129155e1b9ea458b48529a7644b3b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3fe07e736d776bdcdc594ef9d4cb3e91aea4c0cf81d6bf9c0f703d5de744e2d60394b79c4bad02c32b98ac04fc99d3b1a6746c88ff8e377e94e259765ddbfec
|
|
7
|
+
data.tar.gz: eb3c04c9cea640d1c6662e602a77b1b49e4e5e7204cb6c587916440c5695064a84219348127b7282ee83b9dd6e6fa57dced2ca9dd79fd7eba1cb1e33e56d6419
|
data/CHANGELOG.md
CHANGED
|
@@ -6,8 +6,100 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
|
|
9
10
|
### Added
|
|
10
11
|
|
|
12
|
+
- **`bin/release` — the release front door, ported from pgbus.** Works out the
|
|
13
|
+
next version (`patch` by default, `minor`, `major`, or an explicit `X.Y.Z`
|
|
14
|
+
with an optional `v`), prints the commits since the last tag, and hands off
|
|
15
|
+
to `rake release[X.Y.Z]` after a y/N confirm. `list` and `--dry-run` are
|
|
16
|
+
read-only. It refuses to run off anything but a clean, up-to-date `main` —
|
|
17
|
+
the rake task pushes `origin main`, so starting anywhere else either fails
|
|
18
|
+
the push or ships whatever local commits happen to be sitting there — and
|
|
19
|
+
refuses an existing tag unless `--force` (which maps to `release[X.Y.Z,force]`).
|
|
20
|
+
It also warns when `version.rb` and the newest `v*` tag disagree.
|
|
21
|
+
|
|
22
|
+
- **Async-action lifecycle: `reply.pending` + `reactive_settle` (#248).** An
|
|
23
|
+
action that *enqueues* work can now reply truthfully. The endpoint renders the
|
|
24
|
+
reply inside the action's transaction while the queue publishes on commit, so
|
|
25
|
+
any `reply.morph` after an enqueue is guaranteed to draw the pre-job world —
|
|
26
|
+
rows still present, buttons still live — beside the "Queued 177" flash the
|
|
27
|
+
same reply emitted. Apps worked around it with a `queued:` kwarg threaded into
|
|
28
|
+
every row component plus a second render branch; ~60 lines per screen, and the
|
|
29
|
+
page still never learned the outcome.
|
|
30
|
+
|
|
31
|
+
`reply.pending(records, in: :collection, job:, args:)` — or the block form,
|
|
32
|
+
`reply.pending(records, in: :collection) { MyService.call(...) }`, which every
|
|
33
|
+
ActiveJob enqueued inside captures — marks the targets with
|
|
34
|
+
`data-reactive-pending` + `aria-busy` (style it in one CSS rule), opens ONE
|
|
35
|
+
shared durable subscription anchored on the container, and hands the
|
|
36
|
+
fulfilment to your job. The job includes `Phlex::Reactive::Settles` and calls
|
|
37
|
+
`reactive_settle { |s| s.remove(record) }` (also `replace` / `append` /
|
|
38
|
+
`prepend` / `move(from:, to:)` / `count` / `flash` / `js` / `streams!`), which
|
|
39
|
+
emits the row **plus** the count companion **plus** the 0↔1 empty-state
|
|
40
|
+
toggle. `peers: true` broadcasts the same delta to the container's record
|
|
41
|
+
stream for a second operator watching the batch.
|
|
42
|
+
|
|
43
|
+
The handle rides **ActiveJob metadata**, so `perform`'s arity is untouched and
|
|
44
|
+
every other caller of the same job — a nightly sweep, a webhook — runs
|
|
45
|
+
unchanged with `reactive_settle` as a no-op. The `job:`/`args:` form narrows
|
|
46
|
+
each job's handle to its own record, so a job that raises clears exactly that
|
|
47
|
+
row's markers before re-raising for the retry policy. No client changes: the
|
|
48
|
+
markers ride the existing `reactive:js` op lane and the subscription is the
|
|
49
|
+
`reactive:defer` push-lane wire from #165.
|
|
50
|
+
|
|
51
|
+
- **`Phlex::Reactive::Collections` — the collection bookkeeping is now a public
|
|
52
|
+
module (#248).** The count companion and the 0↔1 empty-state boundary used to
|
|
53
|
+
live in `Response`'s privates, reachable only from `reply.*`, so a job or a
|
|
54
|
+
broadcast had to re-derive them by hand and got the boundary subtly wrong.
|
|
55
|
+
`Response.build_collection_*` now delegates, and the settle and broadcast
|
|
56
|
+
paths read the same `count_refresh` / `empty_toggle` decisions — they cannot
|
|
57
|
+
drift. No behavior change for existing `reply.append` / `reply.remove` calls,
|
|
58
|
+
except that each delta now resolves the `size:` proc **once** instead of twice
|
|
59
|
+
(one fewer query per add/remove, and the count companion can no longer
|
|
60
|
+
disagree with the empty-state toggle it ships beside when a concurrent write
|
|
61
|
+
lands between the two reads).
|
|
62
|
+
|
|
63
|
+
- **`Container.broadcast_collection_to(*keys, container:, in:, append:/prepend:/remove:)`
|
|
64
|
+
(#248).** The broadcast-side counterpart of `reply.append` / `reply.remove`:
|
|
65
|
+
the row **plus** the count companion **plus** the empty-state toggle, instead
|
|
66
|
+
of the bare row `broadcast_to(append:)` emits. `coalesce:` applies to the
|
|
67
|
+
aggregate streams only (idempotent replaces of stable targets), so a 177-row
|
|
68
|
+
fan-out collapses to a handful of count refreshes; the row stream is never
|
|
69
|
+
coalesced. Needs pgbus with zoolutions/pgbus#465 — a thread-local an older
|
|
70
|
+
pgbus simply ignores, so it degrades to "chattier, equally correct" rather
|
|
71
|
+
than breaking.
|
|
72
|
+
|
|
73
|
+
- **`Phlex::Reactive.settle_coalesce_window_ms` (50) and `.settle_capable?`
|
|
74
|
+
(#248).** The window governs the aggregate (count / empty-state) streams on
|
|
75
|
+
the peers path. `settle_capable?` reports whether `reply.pending` has a lane
|
|
76
|
+
at all; without one it degrades to a plain enqueue (no markers, no lie) with a
|
|
77
|
+
one-time warning. There is deliberately no `settle_token_ttl` — a settle has
|
|
78
|
+
no pull lane for a token to govern.
|
|
79
|
+
|
|
80
|
+
- **`reactive_persist` drafts rich editors (#241).** A named `lexxy-editor`,
|
|
81
|
+
`trix-editor` or bare `[contenteditable]` inside a `reactive_persist` root is
|
|
82
|
+
now drafted and restored through its **own** value surface — the editor's
|
|
83
|
+
`value` getter/setter (its serialized HTML, replayed through the editor's own
|
|
84
|
+
sanitizing import: Trix `HTMLParser`, Lexxy `$generateNodesFromDOM` +
|
|
85
|
+
sanitizer), a bare contenteditable's `textContent` — never `innerHTML`. The
|
|
86
|
+
name resolves from the element's `name` attribute or, for Trix in the Rails
|
|
87
|
+
`rich_text_area` shape, from the `input=`-paired hidden input (never a
|
|
88
|
+
separate key). `restore: :blank` asks the editor whether it is empty (Lexxy
|
|
89
|
+
`isEmpty`, Trix `editor.getDocument().isEmpty()`, an exact-string fallback),
|
|
90
|
+
so an attachment-only server body is never overwritten. An editor not yet
|
|
91
|
+
upgraded at connect (Trix defines its elements in a `setTimeout`; a lazily
|
|
92
|
+
imported Lexxy) is restored once `customElements.whenDefined` resolves; a
|
|
93
|
+
throwing setter never escapes `connect()` (one `console.info` under
|
|
94
|
+
`Phlex::Reactive.debug`). The editors' own `lexxy:change` / `trix-change`
|
|
95
|
+
events drive the debounced write (neither lets its native `input` bubble),
|
|
96
|
+
and their toolbar chrome (Lexxy's code-language select and link `href`
|
|
97
|
+
input, Trix's `trix-toolbar`) is never drafted. `fields:`,
|
|
98
|
+
`reactive_persist_skip` (on the editor element) and nested-root ownership
|
|
99
|
+
apply unchanged. The dummy vendors the
|
|
100
|
+
real Trix 2.1.19 and Lexxy 0.9.31 and the browser suite drives both under
|
|
101
|
+
Puma and Falcon.
|
|
102
|
+
|
|
11
103
|
- **`reactive_persist` — client-only localStorage drafts (#239).** "Don't make
|
|
12
104
|
me start over": spread `reactive_persist(key:, ttl: 7.days)` on a root and
|
|
13
105
|
the generic controller keeps a `localStorage` draft of every **owned**
|
|
@@ -22,8 +114,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
22
114
|
`js.persist_state(step: 2)` merges a flat state bag into the draft (restored
|
|
23
115
|
as `data-reactive-persist-state` + the `reactive:persist-restored` event).
|
|
24
116
|
`hidden`/`file`/`password`/`submit` controls, `reactive_persist_skip`
|
|
25
|
-
markers
|
|
26
|
-
narrows the set. Storage failures are silent (a `console.info` under
|
|
117
|
+
markers and nested roots are never persisted; `fields:` narrows the set. Storage failures are silent (a `console.info` under
|
|
27
118
|
`Phlex::Reactive.debug`). Works from `ClientBindings` (no token, no POST).
|
|
28
119
|
|
|
29
120
|
- **Dev-mode warning when a client op resolves zero targets (#237).** A client
|
|
@@ -350,6 +441,33 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
350
441
|
|
|
351
442
|
### Fixed
|
|
352
443
|
|
|
444
|
+
- **`rake release` bumps the pin in every tracked lockfile (#247, #253).** Since
|
|
445
|
+
#246 the gem root's `Gemfile.lock` is committed alongside `docs/Gemfile.lock`,
|
|
446
|
+
and both pin `phlex-reactive` by local path — so a version bump that left them
|
|
447
|
+
alone shipped a stale lockfile, and the Release workflow's frozen
|
|
448
|
+
`bundle install` refused it (v0.13.0's first attempt).
|
|
449
|
+
|
|
450
|
+
The task now bumps that pin **with a text edit**, not `bundle lock`. #247's
|
|
451
|
+
first cut used `bundle lock --local`, which is a full re-resolve — and a
|
|
452
|
+
re-resolve trips over constraints that have nothing to do with this gem:
|
|
453
|
+
`docs/Gemfile.lock` declares Linux platforms for the Kamal deploy, and
|
|
454
|
+
resolving `thruster` for those against a Mac's installed gems fails ("Could
|
|
455
|
+
not find gems matching 'thruster' valid for all resolution platforms"), which
|
|
456
|
+
aborted v0.13.1's first attempt mid-release with `version.rb` already bumped.
|
|
457
|
+
A re-resolve also silently folds unrelated dependency drift into the release
|
|
458
|
+
commit the moment a Gemfile is out of sync with its lock. The only line a
|
|
459
|
+
version bump changes is the path-gem pin, so the task edits exactly that (the
|
|
460
|
+
`PATH` spec + the `CHECKSUMS` entry) in place — deterministic on any machine,
|
|
461
|
+
no network, no installed gems, the same 2-line diff bundler produced. A
|
|
462
|
+
lockfile that names no pin at all now **aborts** the release rather than
|
|
463
|
+
reporting itself already current — and that check runs in a PREFLIGHT, before
|
|
464
|
+
the task does anything destructive or irreversible: before the `force`
|
|
465
|
+
cleanup that deletes the GitHub release and its tag, and before `version.rb`
|
|
466
|
+
or any lockfile is written. So an abort leaves the release intact and the tree
|
|
467
|
+
clean, rather than a deleted release or a half-bumped tree the clean-tree
|
|
468
|
+
guard would then block on retry.
|
|
469
|
+
pgbus's release task made the same call after the same failure.
|
|
470
|
+
|
|
353
471
|
- **`Component::Action` renamed `ActionDefinition` — it shadowed a host kit
|
|
354
472
|
component named `Action` under dev autoloading (#233).** The mixin sits in
|
|
355
473
|
every reactive component's ancestry, so its bare `Action` constant satisfied
|
|
@@ -486,6 +604,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
486
604
|
|
|
487
605
|
### Changed
|
|
488
606
|
|
|
607
|
+
- **Client build toolchain: bun 1.3.14 → 1.4.0.** `.bun-version`, the root `engines.bun`
|
|
608
|
+
floor, and the docs `packageManager` pin all move together. The shipped
|
|
609
|
+
`*.min.js` / `.map` artifacts (and their vendored twins under
|
|
610
|
+
`spec/dummy/public/vendor`) are rebuilt — the 1.4 minifier picks different
|
|
611
|
+
local identifier names and sorts export lists, so the bytes differ, but the
|
|
612
|
+
code is semantically identical (JS unit, request, and browser suites unchanged).
|
|
613
|
+
|
|
489
614
|
- **BREAKING: small sharp knives — the last 0.11 API-clarity pass (#186).**
|
|
490
615
|
Four independent edges honed, one contract frozen:
|
|
491
616
|
- **`reactive_filter` speaks fields, not selectors.** `reactive_filter(:q)` names the
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# phlex-reactive
|
|
2
2
|
|
|
3
|
-
[](https://github.com/zoolutions/phlex-reactive/actions/workflows/main.yml)
|
|
4
4
|
[](https://rubygems.org/gems/phlex-reactive)
|
|
5
5
|
[](https://phlex-reactive.zoolutions.llc)
|
|
6
6
|
|
|
@@ -62,7 +62,7 @@ follows* — and implements it the Rails way:
|
|
|
62
62
|
- **One tiny client runtime.** A single generic Stimulus controller, registered
|
|
63
63
|
once, handles every reactive component. You don't write per-feature JS.
|
|
64
64
|
|
|
65
|
-
Pair it with [**pgbus**](https://github.com/
|
|
65
|
+
Pair it with [**pgbus**](https://github.com/zoolutions/pgbus) and your live
|
|
66
66
|
updates become *transactional* (no broadcast for a rolled-back change) and
|
|
67
67
|
*reconnect-safe* (missed messages replay) over Postgres SSE — **no Action Cable,
|
|
68
68
|
no Redis.**
|
|
@@ -406,6 +406,8 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
406
406
|
| `reactive_collection :name, item:, container:, count:, empty:, size:` | Declare an add/remove-row list once; actions call `reply.append`/`prepend`/`remove`. See [Reactive collections](#reactive-collections-addremove-rows--count--empty-state). |
|
|
407
407
|
| `reply.replace` / `.morph` / `.update` / `.remove` / `.redirect(url)` / `.with(*)` / `.js(ops)` | Return from an action to control the reply (flash, remove, redirect, multi-stream, server-pushed client ops). See [Controlling the action's reply](#reply--controlling-the-actions-reply). |
|
|
408
408
|
| `reply.append(name, model)` / `.prepend(...)` / `.remove(name, model)` | Add/remove a row in a declared `reactive_collection` (row + count + empty-state in one reply). |
|
|
409
|
+
| `reply.pending(records, in:, job:) { … }` + `include Phlex::Reactive::Settles` / `reactive_settle` | For an action that **enqueues** work: mark targets pending, let your job settle them (row + count + empty-state) when it finishes. See [Async actions](#async-actions-replypending--reactive_settle). |
|
|
410
|
+
| `Container.broadcast_collection_to(*keys, container:, in:, append:/prepend:/remove:)` | The broadcast-side `reply.append`/`reply.remove`: the row **plus** the count companion **plus** the empty-state toggle, to peers. |
|
|
409
411
|
|
|
410
412
|
Param types: `:string` (default), `:integer`, `:float`, `:boolean`, `:file`,
|
|
411
413
|
`:date`, `:datetime`, `:decimal`. Anything not in the schema is dropped before
|
|
@@ -1160,11 +1162,28 @@ wire attr: `data-reactive-persist='{"key":"village-apply","ttl":604800,"debounce
|
|
|
1160
1162
|
- **`fields:`** narrows the set to declared names (scope-aware symbols):
|
|
1161
1163
|
`reactive_persist(key: "k", fields: %i[name bio])`.
|
|
1162
1164
|
- **Never persisted**: `type=hidden/file/password/submit/button/reset/image`,
|
|
1163
|
-
anything carrying `reactive_persist_skip`, a nested reactive root's
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1165
|
+
anything carrying `reactive_persist_skip`, and a nested reactive root's
|
|
1166
|
+
controls. `autocomplete="off"` is **not** an implicit skip — a wizard often
|
|
1167
|
+
sets it form-wide. **Honeypots must opt out** (`reactive_persist_skip`) or sit
|
|
1168
|
+
outside the root: an invisible-captcha text input looks like any other field.
|
|
1169
|
+
- **Rich editors** (#241) — a named `lexxy-editor`, `trix-editor` or bare
|
|
1170
|
+
`[contenteditable]` is drafted and restored through its **own** value
|
|
1171
|
+
surface: the editor's `value` getter/setter (its serialized HTML, replayed
|
|
1172
|
+
through the editor's own sanitizing import — Trix's `HTMLParser`, Lexxy's
|
|
1173
|
+
`$generateNodesFromDOM` + sanitizer), a bare contenteditable's `textContent`.
|
|
1174
|
+
The name is the element's `name` attribute, or — Trix in the Rails
|
|
1175
|
+
`rich_text_area` shape — the `input=`-paired hidden input's name (the hidden
|
|
1176
|
+
input itself is never a separate key). `restore: :blank` **asks the editor**
|
|
1177
|
+
whether it is empty (Lexxy `isEmpty`, Trix `editor.getDocument().isEmpty()`),
|
|
1178
|
+
so an attachment-only server body is never overwritten. An editor that hasn't
|
|
1179
|
+
upgraded yet at connect (Trix defines its elements in a `setTimeout`; a lazily
|
|
1180
|
+
imported Lexxy) is restored once `customElements.whenDefined` resolves. The
|
|
1181
|
+
editors' own `lexxy:change` / `trix-change` events are the keystroke signal
|
|
1182
|
+
for the draft write (neither editor lets its native `input` bubble) and also
|
|
1183
|
+
fire on restore (no native `input`/`change` is ever synthesized). Their
|
|
1184
|
+
toolbar chrome (Lexxy's code-language select and link `href` input, Trix's
|
|
1185
|
+
`trix-toolbar`) is never drafted. Put `reactive_persist_skip` on the editor
|
|
1186
|
+
element to opt one out.
|
|
1168
1187
|
- **State bag** — `js.persist_state(step: 2)` merges a flat hash of scalars
|
|
1169
1188
|
into the same draft (a wizard's current step). On restore the root carries
|
|
1170
1189
|
`data-reactive-persist-state='{"step":2}'` and dispatches a bubbling
|
|
@@ -1175,8 +1194,11 @@ wire attr: `data-reactive-persist='{"key":"village-apply","ttl":604800,"debounce
|
|
|
1175
1194
|
prints one `console.info` naming the failure so a dev sees why nothing came
|
|
1176
1195
|
back.
|
|
1177
1196
|
|
|
1178
|
-
Threat model:
|
|
1179
|
-
|
|
1197
|
+
Threat model: phlex-reactive never writes markup into the DOM — native controls
|
|
1198
|
+
are replayed via `.value`/`.checked`/`.selected`, a bare contenteditable via
|
|
1199
|
+
`textContent`, and a rich editor through its own `value` setter (the same
|
|
1200
|
+
sanitizing import a paste takes). A tampered draft can only produce what the
|
|
1201
|
+
user could type or paste into that field. PII sits in this
|
|
1180
1202
|
browser's `localStorage` for `ttl`; the submit-clear and `ttl` are the
|
|
1181
1203
|
shared-computer mitigation. `persist_state`/`persist_clear` are **actor-only**
|
|
1182
1204
|
ops (refused by `broadcast_to(js:)`). See the [security page](docs/security.md).
|
|
@@ -1864,6 +1886,7 @@ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams
|
|
|
1864
1886
|
| `reply.streams(*streams)` | **partial update** — emit exactly these streams (no full-self replace) + a tiny token-only refresh, so live inputs survive; for per-field grid editing (issue #30) |
|
|
1865
1887
|
| `.js(ops, target: …)` | also push **client DOM ops** (focus, dispatch, class/attr toggles) over a `reactive:js` stream, applied AFTER the render — `reply.morph.js(js.focus("[name=next]"))` focuses the morphed field (issue #97) |
|
|
1866
1888
|
| `.defer(component, placeholder:, morph:)` | take an **expensive segment off the actor's critical path** (issue #165) — the reply returns immediately and the real render streams to the SAME actor when ready; see [Deferred segments](#deferred-segments-replydefer--reactive_lazy) |
|
|
1889
|
+
| `reply.pending(records, in:, job:, args:, peers:) { … }` | the action **enqueues** the work: mark the targets pending now and let **your own job** settle them when the work is actually done (issue #248); see [Async actions](#async-actions-replypending--reactive_settle) |
|
|
1867
1890
|
| `reply.with(*streams)` / `#stream(*more)` | multi-stream (self re-render still injected for the token) |
|
|
1868
1891
|
|
|
1869
1892
|
`.flash`/`.stream`/`.also` are additive on a self-replace, so the component's
|
|
@@ -2429,11 +2452,240 @@ end
|
|
|
2429
2452
|
- **`remove` takes the record or its `dom_id` string** — a just-destroyed
|
|
2430
2453
|
ActiveRecord still answers `dom_id` correctly, so `reply.remove(todo, from: :items)`
|
|
2431
2454
|
works; pass the raw id only if your row `#id` matches `ActiveRecord::RecordIdentifier`.
|
|
2432
|
-
- **Reply governs the actor's HTTP response only.** For a *cross-tab* live list
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2455
|
+
- **Reply governs the actor's HTTP response only.** For a *cross-tab* live list,
|
|
2456
|
+
broadcast the delta with `NotificationsList.broadcast_collection_to(*keys,
|
|
2457
|
+
container: self, in: :notifications, append: model, exclude: reactive_connection_id)`
|
|
2458
|
+
— that emits the row **plus** the count companion **plus** the empty-state
|
|
2459
|
+
toggle, through the same decisions the reply path uses. (Plain
|
|
2460
|
+
`broadcast_to(append:)` still works and still emits the **bare row**: it has no
|
|
2461
|
+
container instance, so it cannot resolve the declaration or run `size:`.)
|
|
2462
|
+
- **When the work is asynchronous**, an action that merely enqueues it cannot
|
|
2463
|
+
reply with a delta at all — the reply renders before the job touches the
|
|
2464
|
+
database. Use [`reply.pending`](#async-actions-replypending--reactive_settle)
|
|
2465
|
+
and let the job settle the row.
|
|
2466
|
+
|
|
2467
|
+
### Async actions (`reply.pending` + `reactive_settle`)
|
|
2468
|
+
|
|
2469
|
+
An action that **does** the work can reply honestly. An action that
|
|
2470
|
+
**enqueues** the work cannot — and until issue #248 every app worked around it
|
|
2471
|
+
the same way.
|
|
2472
|
+
|
|
2473
|
+
The endpoint runs your action inside a transaction and renders the reply
|
|
2474
|
+
*there*, while the queue adapter publishes on **commit**. So any `reply.morph`
|
|
2475
|
+
after an enqueue renders from a database the job has not touched yet, and is
|
|
2476
|
+
*guaranteed* to draw the pre-job world — rows still present, buttons still live,
|
|
2477
|
+
counts unchanged — sitting right beside the "Queued 177 transfers" flash the
|
|
2478
|
+
same reply emitted:
|
|
2479
|
+
|
|
2480
|
+
```ruby
|
|
2481
|
+
def restore_all
|
|
2482
|
+
count = BatchRestoreService.call(bulk_payment: @bulk_payment) # fans out N jobs
|
|
2483
|
+
reply.morph.flash(:notice, "Putting #{count} back…") # renders the PRE-job world
|
|
2484
|
+
end
|
|
2485
|
+
```
|
|
2486
|
+
|
|
2487
|
+
The usual fix is a `queued:` kwarg threaded into every row component with a
|
|
2488
|
+
second render branch, a `@queued_*` flag on the container, and the header
|
|
2489
|
+
button's count forced to `0` — ~60 lines of identical bookkeeping per screen.
|
|
2490
|
+
And it still never tells the operator the **outcome**: the page says "Queued"
|
|
2491
|
+
forever until someone reloads.
|
|
2492
|
+
|
|
2493
|
+
`reply.pending` replaces all of it:
|
|
2494
|
+
|
|
2495
|
+
```ruby
|
|
2496
|
+
class ReconcileQueue < ApplicationComponent
|
|
2497
|
+
include Phlex::Reactive::Component
|
|
2498
|
+
|
|
2499
|
+
reactive_collection :unreconcilable,
|
|
2500
|
+
item: TransferRow, container: "unreconcilable",
|
|
2501
|
+
count: "unreconcilable-count", empty: NothingToReconcile,
|
|
2502
|
+
size: -> { @bulk_payment.transfers.unreconcilable.count }
|
|
2503
|
+
|
|
2504
|
+
action :re_execute, params: {transfer_id: :integer}
|
|
2505
|
+
action :restore_all
|
|
2506
|
+
|
|
2507
|
+
# ONE record — the job settles it, and the subscription tears itself down.
|
|
2508
|
+
def re_execute(transfer_id:)
|
|
2509
|
+
transfer = @bulk_payment.transfers.re_executable.find(transfer_id)
|
|
2510
|
+
authorize! transfer, :update?
|
|
2511
|
+
reply.pending(transfer, in: :unreconcilable, job: ReExecuteJob, args: [transfer.id])
|
|
2512
|
+
end
|
|
2513
|
+
|
|
2514
|
+
# A FAN-OUT — the enqueue lives in the block, so the service object's own
|
|
2515
|
+
# perform_later calls capture the settle handle too.
|
|
2516
|
+
def restore_all
|
|
2517
|
+
authorize! @bulk_payment, :update?
|
|
2518
|
+
restorable = @bulk_payment.transfers.restorable.to_a
|
|
2519
|
+
reply.pending(restorable, in: :declined, peers: true) do
|
|
2520
|
+
BatchRestoreService.call(bulk_payment: @bulk_payment)
|
|
2521
|
+
end.flash(:notice, "Putting #{restorable.size} back…")
|
|
2522
|
+
end
|
|
2523
|
+
end
|
|
2524
|
+
```
|
|
2525
|
+
|
|
2526
|
+
and the job settles it when the work is **actually** done:
|
|
2527
|
+
|
|
2528
|
+
```ruby
|
|
2529
|
+
class ReExecuteJob < ApplicationJob
|
|
2530
|
+
include Phlex::Reactive::Settles
|
|
2531
|
+
|
|
2532
|
+
def perform(transfer_id) # signature UNCHANGED
|
|
2533
|
+
transfer = Transfer.find(transfer_id)
|
|
2534
|
+
result = Transfers::ReExecuteService.call(transfer:)
|
|
2535
|
+
|
|
2536
|
+
reactive_settle do |s|
|
|
2537
|
+
if result.success?
|
|
2538
|
+
s.remove(transfer) # row + count + empty-state
|
|
2539
|
+
else
|
|
2540
|
+
s.replace(transfer) # back to actionable
|
|
2541
|
+
s.flash(:alert, result.error) # the operator learns the outcome
|
|
2542
|
+
end
|
|
2543
|
+
end
|
|
2544
|
+
end
|
|
2545
|
+
end
|
|
2546
|
+
```
|
|
2547
|
+
|
|
2548
|
+
and the case that motivated the whole thing — work that moves a record between
|
|
2549
|
+
two lists — is one call:
|
|
2550
|
+
|
|
2551
|
+
```ruby
|
|
2552
|
+
reactive_settle { |s| s.move(transfer, from: :declined, to: :unreconcilable) }
|
|
2553
|
+
```
|
|
2554
|
+
|
|
2555
|
+
#### What the reply actually does
|
|
2556
|
+
|
|
2557
|
+
`reply.pending` deliberately does **NOT** re-render the container (that is the
|
|
2558
|
+
bug). It emits:
|
|
2559
|
+
|
|
2560
|
+
1. a `data-reactive-pending="true"` + `aria-busy="true"` marker on every target,
|
|
2561
|
+
over the existing `reactive:js` op lane — style it in one CSS rule:
|
|
2562
|
+
|
|
2563
|
+
```css
|
|
2564
|
+
[data-reactive-pending] { opacity: .5; pointer-events: none; }
|
|
2565
|
+
```
|
|
2566
|
+
|
|
2567
|
+
2. **one** subscription directive, anchored on the container, opening a
|
|
2568
|
+
single durable one-shot stream that **all N settles share**;
|
|
2569
|
+
3. an inert `reactive:token` refresh, so the container's signed token rolls
|
|
2570
|
+
forward and the list is not act-once-only.
|
|
2571
|
+
|
|
2572
|
+
| `reply.pending(...)` | |
|
|
2573
|
+
|---|---|
|
|
2574
|
+
| `records` | one record, an enumerable of records, or built Streamable components |
|
|
2575
|
+
| `in: :name` | the `reactive_collection` the records live in — how their row DOM ids *and* the count/empty-state bookkeeping are resolved (required for records) |
|
|
2576
|
+
| a block | your enqueue. **Anything ActiveJob-enqueued inside it captures the settle handle**, including from a service object |
|
|
2577
|
+
| `job:` / `args:` | sugar for the common case. `args:` is an Array (one record) or a Proc called per record (`args: ->(r) { [r.id] }`); omitted means `perform_later(record)` |
|
|
2578
|
+
| `peers: true` | also broadcast each settle to the container's record stream, so a second operator watching the same batch sees it. **Default is actor-only**, matching `reply.defer` |
|
|
2579
|
+
|
|
2580
|
+
| `reactive_settle` yields a settle builder | |
|
|
2581
|
+
|---|---|
|
|
2582
|
+
| `s.replace(record)` | re-render the row in place (no count churn — a replace moves no boundary) |
|
|
2583
|
+
| `s.remove(record, from: :name)` | row + count + empty-state restore. `from:` defaults to the collection `reply.pending` named |
|
|
2584
|
+
| `s.append(record, to: :name)` / `s.prepend` | row + count + empty-state clear |
|
|
2585
|
+
| `s.move(record, from:, to:)` | ordered remove-then-append between two collections |
|
|
2586
|
+
| `s.count(:name)` | refresh only the count companion |
|
|
2587
|
+
| `s.flash(level, content)` | tell the operator the outcome |
|
|
2588
|
+
| `s.js(ops)` / `s.streams!(*raw)` | the `reply.js` / `reply.streams` escape hatches |
|
|
2589
|
+
|
|
2590
|
+
#### The rules that make it safe
|
|
2591
|
+
|
|
2592
|
+
- **The handle rides ActiveJob metadata, not `perform`'s arity.** `reply.pending`
|
|
2593
|
+
installs it in a thread-local and runs your enqueue inside it;
|
|
2594
|
+
`Settles#serialize` copies it into the job's metadata. So **every other caller
|
|
2595
|
+
of the same job — a nightly sweep, a webhook — keeps working unchanged**, and
|
|
2596
|
+
`reactive_settle` is simply a no-op there. That is load-bearing: these jobs
|
|
2597
|
+
almost always have non-UI callers.
|
|
2598
|
+
- **A job that raises still clears the pending state — when it can attribute
|
|
2599
|
+
it.** The `job:`/`args:` form enqueues one job per record and narrows each
|
|
2600
|
+
job's handle to *that record's* target, so a failure clears exactly its row
|
|
2601
|
+
and the container, then re-raises for your retry policy. The **block** form
|
|
2602
|
+
cannot be narrowed (the gem cannot map an arbitrary enqueue back to a record),
|
|
2603
|
+
so a failure there clears nothing and logs why — un-dimming 176 rows that are
|
|
2604
|
+
still working would be a worse lie. Those markers clear at `finish: true`.
|
|
2605
|
+
The subscription is deliberately **not** torn down on failure — a retry must
|
|
2606
|
+
still be able to reach the actor.
|
|
2607
|
+
- **Finishing clears every target, not just the container.** A settle that only
|
|
2608
|
+
flashes emits no row stream, so nothing swaps that row's node — the finish
|
|
2609
|
+
sweep is what removes its markers.
|
|
2610
|
+
- **ONE stream key per `reply.pending` call.** A durable broadcast to a
|
|
2611
|
+
never-seen key creates a real PGMQ table (reclaimed by pgbus's hourly orphan
|
|
2612
|
+
sweep at a 24h threshold), so a key per record would leave 177 tables sitting
|
|
2613
|
+
for a day and open 177 SSE connections. All N settles share one key and one
|
|
2614
|
+
subscription.
|
|
2615
|
+
- **Teardown is therefore explicit.** Because the key is shared, tearing down on
|
|
2616
|
+
the *first* arrival would cut off the other N−1. `reactive_settle` finishes
|
|
2617
|
+
automatically when `reply.pending` marked exactly **one** target; a fan-out
|
|
2618
|
+
passes `finish: true` from whatever knows it is last (a `Pgbus::Batch`
|
|
2619
|
+
`on_finish` callback, or the final job of a staggered sequence). Until then
|
|
2620
|
+
the subscription is superseded by the container's next `reply.pending` or
|
|
2621
|
+
closed when the page unloads.
|
|
2622
|
+
- **`reply.pending` needs the defer PUSH lane**
|
|
2623
|
+
(`Phlex::Reactive.settle_capable?` — pgbus reactive Streams + `SignedName` +
|
|
2624
|
+
ActiveJob, and `defer_transport` not forced to `:fetch`). A settle has no pull
|
|
2625
|
+
fallback: the client cannot poll "is the job done yet". **Without it,
|
|
2626
|
+
`reply.pending` degrades rather than breaks** — your enqueue still runs, no
|
|
2627
|
+
handle is installed, and **no pending markers are emitted**, so the UI shows
|
|
2628
|
+
the pre-job world (today's behavior) instead of a shimmer that could never
|
|
2629
|
+
resolve. A one-time warning says so.
|
|
2630
|
+
- **The pgbus CLIENT must be on the page too.** The server picks the push lane on
|
|
2631
|
+
*server-side* capability alone. If the browser has no `<pgbus-stream-source>`
|
|
2632
|
+
custom element registered, `reply.defer` degrades to its fetch token — but a
|
|
2633
|
+
settle has no fetch lane, so the subscription never opens and the pending
|
|
2634
|
+
markers would sit there. The client logs a loud console error naming the cause.
|
|
2635
|
+
Load pgbus's client wherever you use `reply.pending`; it is the same
|
|
2636
|
+
prerequisite the defer push lane already has.
|
|
2637
|
+
- **Authorization is still yours.** `reply.pending` signs the *container's*
|
|
2638
|
+
identity so the job can rebuild it; that is not permission to act. `authorize!`
|
|
2639
|
+
in the action, exactly as everywhere else.
|
|
2640
|
+
- **One `reply.pending` per container, per reply.** Every pending segment emits a
|
|
2641
|
+
directive targeting the container's id, and the client keys subscriptions by
|
|
2642
|
+
target — so a second call would supersede the first and orphan its jobs. The
|
|
2643
|
+
second call raises. Mark every target in one call; the settle names its own
|
|
2644
|
+
collection (`s.remove(record, from: :name)`).
|
|
2645
|
+
- **Peer delivery is best effort.** If a peer broadcast fails after the actor's
|
|
2646
|
+
message already went out, the job is *not* failed — retrying it would re-run
|
|
2647
|
+
`perform` and send the actor's settle (and its flash) a second time. The
|
|
2648
|
+
failure is logged instead.
|
|
2649
|
+
|
|
2650
|
+
#### Broadcasting a collection delta to peers
|
|
2651
|
+
|
|
2652
|
+
`broadcast_to(append:)` emits the **bare row** — it has no container instance,
|
|
2653
|
+
so it cannot resolve the declaration or run the size resolver. Its collection
|
|
2654
|
+
counterpart does:
|
|
2655
|
+
|
|
2656
|
+
```ruby
|
|
2657
|
+
ReconcileQueue.broadcast_collection_to(@bulk_payment, :transfers,
|
|
2658
|
+
container: self, in: :unreconcilable, remove: transfer,
|
|
2659
|
+
exclude: reactive_connection_id)
|
|
2660
|
+
```
|
|
2661
|
+
|
|
2662
|
+
`row:` carries the row component's extra init kwargs — pass the same ones the
|
|
2663
|
+
actor got, or a peer whose row has a required kwarg raises instead of rendering.
|
|
2664
|
+
A `remove:` may be a record or an already-built dom-id string, matching
|
|
2665
|
+
`reply.remove(id, from:)`.
|
|
2666
|
+
|
|
2667
|
+
Row **plus** count companion **plus** empty-state toggle, through the same
|
|
2668
|
+
`Phlex::Reactive::Collections` decisions the reply path uses — so the two can
|
|
2669
|
+
never drift. `coalesce:` (default `Phlex::Reactive.settle_coalesce_window_ms`,
|
|
2670
|
+
50 ms) applies to the **aggregate** streams only: the count and empty-state are
|
|
2671
|
+
idempotent replaces of stable targets, so a 177-row fan-out collapses to a
|
|
2672
|
+
handful of them. The **row** stream is never coalesced (an append is not
|
|
2673
|
+
idempotent). Coalescing needs pgbus with
|
|
2674
|
+
[zoolutions/pgbus#465](https://github.com/zoolutions/pgbus/issues/465); on
|
|
2675
|
+
anything older the window is ignored and every aggregate goes out — chattier,
|
|
2676
|
+
equally correct.
|
|
2677
|
+
|
|
2678
|
+
#### Settle configuration
|
|
2679
|
+
|
|
2680
|
+
| Setting | Default | Purpose |
|
|
2681
|
+
|---|---|---|
|
|
2682
|
+
| `Phlex::Reactive.settle_coalesce_window_ms` | `50` | Window for the aggregate (count / empty-state) streams on the peers path. |
|
|
2683
|
+
|
|
2684
|
+
There is deliberately **no** `settle_token_ttl`. A settle has no pull lane for a
|
|
2685
|
+
token to govern — the client cannot poll "is the job done yet", and redeeming
|
|
2686
|
+
such a token at the defer endpoint would render the *pre-job* component, i.e.
|
|
2687
|
+
the exact bug `reply.pending` fixes. The settle's wait is bounded by the job,
|
|
2688
|
+
not by a TTL.
|
|
2437
2689
|
|
|
2438
2690
|
### Effects — animate enter/exit/update (opt-in)
|
|
2439
2691
|
|
|
@@ -2786,7 +3038,7 @@ end
|
|
|
2786
3038
|
|
|
2787
3039
|
## Live updates with pgbus (recommended)
|
|
2788
3040
|
|
|
2789
|
-
[pgbus](https://github.com/
|
|
3041
|
+
[pgbus](https://github.com/zoolutions/pgbus) replaces Action Cable's transport
|
|
2790
3042
|
with Postgres SSE and fixes its reliability gaps. With it installed,
|
|
2791
3043
|
`broadcast_to` and `turbo_stream_from` route over pgbus automatically:
|
|
2792
3044
|
|
|
@@ -3004,7 +3256,7 @@ The mental model is stolen, gratefully, from
|
|
|
3004
3256
|
[Laravel Livewire](https://livewire.laravel.com) (public method = action) and
|
|
3005
3257
|
[Phoenix LiveView](https://www.phoenixframework.org) (a component is a re-render
|
|
3006
3258
|
unit). The transport and reliability come from
|
|
3007
|
-
[pgbus](https://github.com/
|
|
3259
|
+
[pgbus](https://github.com/zoolutions/pgbus). The rendering is all
|
|
3008
3260
|
[Phlex](https://www.phlex.fun).
|
|
3009
3261
|
|
|
3010
3262
|
## License
|
|
@@ -403,7 +403,21 @@ module Phlex
|
|
|
403
403
|
end
|
|
404
404
|
end
|
|
405
405
|
|
|
406
|
-
append_deferred_streams(streams, result)
|
|
406
|
+
append_pending_streams(append_deferred_streams(streams, result), result)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Pending segments (issue #248) ride LAST, alongside the deferred ones and
|
|
410
|
+
# for the same reason: this runs AFTER run_action returned, i.e. after the
|
|
411
|
+
# action's transaction COMMITTED — a rolled-back action takes the rescue
|
|
412
|
+
# paths, so no pending marker and no subscription directive can ever
|
|
413
|
+
# outlive a mutation that did not happen. (The ENQUEUE itself already ran
|
|
414
|
+
# inside the action, exactly like the app's own perform_later would have;
|
|
415
|
+
# its transactional behaviour is the queue adapter's, unchanged.) The
|
|
416
|
+
# common non-pending reply pays one empty? check.
|
|
417
|
+
def append_pending_streams(streams, result)
|
|
418
|
+
return streams unless result.pending?
|
|
419
|
+
|
|
420
|
+
[*streams, *result.pending_segments.flat_map { Phlex::Reactive::Pending.streams_for(it) }]
|
|
407
421
|
end
|
|
408
422
|
|
|
409
423
|
# Deferred segments (issue #165) ride LAST — after every render stream and
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var
|
|
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};
|
|
2
2
|
|
|
3
|
-
//# debugId=
|
|
3
|
+
//# debugId=58A4BBBA7E66031D64756E2164756E21
|
|
4
4
|
//# sourceMappingURL=compute.min.js.map
|
|
@@ -5,6 +5,6 @@
|
|
|
5
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// 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). 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"
|
|
6
6
|
],
|
|
7
7
|
"mappings": "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",
|
|
8
|
-
"debugId": "
|
|
8
|
+
"debugId": "58A4BBBA7E66031D64756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var
|
|
1
|
+
var o=(e)=>Promise.resolve(typeof window<"u"?window.confirm(e):!0);function r(e){o=e}export{o as confirmResolver,r as setConfirmResolver};
|
|
2
2
|
|
|
3
|
-
//# debugId=
|
|
3
|
+
//# debugId=B602C81CFFC471CC64756E2164756E21
|
|
4
4
|
//# sourceMappingURL=confirm.min.js.map
|
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
"// The overridable confirmation resolver behind `on(:action, confirm: \"…\")`\n// (issue #55, follow-up to #52).\n//\n// The reactive controller preempts the click event (its own preventDefault +\n// POST), so Hotwire's `data-turbo-confirm` — which routes through\n// `Turbo.config.forms.confirm` — never runs for a reactive trigger. That made\n// the reactive path the ONE interaction a Hotwire app couldn't theme: every\n// confirmable reactive action showed the unstyled native window.confirm chrome.\n//\n// This module is the seam. `dispatch` resolves the confirm message through\n// `confirmResolver`, which defaults to a Promise-wrapped window.confirm (so the\n// 0.4.5 behavior is byte-for-byte unchanged when nothing is configured: sync,\n// no dependency, screen-reader friendly). An app reuses its themed dialog with\n// one line at boot:\n//\n// import { setConfirmResolver } from \"phlex/reactive/confirm\"\n// setConfirmResolver((message) => window.Turbo.config.forms.confirm(message))\n//\n// The resolver may be sync or async — the controller awaits it via\n// Promise.resolve, so a bare boolean and a Promise<boolean> both work. It must\n// resolve truthy to proceed; a falsy resolve (or a rejection) cancels the\n// action — exactly like declining the native prompt.\n//\n// The resolver receives an OPTIONAL 2nd arg (issue #222): a context object,\n// always `{ el }` (the trigger element), plus `{ row, fields }` on a\n// reactive_nested_remove (the row element + its { key: value } field map). It's\n// purely additive — a one-arg resolver (and window.confirm, which ignores extra\n// args) keeps working unchanged. On a client-added draft row the `message` the\n// resolver receives is already interpolated from the row's live field values\n// (`confirm: \"Delete '%{name}'?\"` → \"Delete 'Widget'?\").\n\n// The default: wrap the synchronous native confirm in a Promise so the call\n// site can always `await` it. Read window lazily (per call), not at module load\n// — under SSR / test the global may not exist yet when this module is imported.\nexport let confirmResolver = (message) =>\n Promise.resolve(typeof window !== \"undefined\" ? window.confirm(message) : true)\n\n// Override the resolver. Pass a function `(message) => boolean | Promise<boolean>`.\n// Truthy resolves the action through; falsy (or a rejected Promise) cancels it.\nexport function setConfirmResolver(fn) {\n confirmResolver = fn\n}\n"
|
|
6
6
|
],
|
|
7
7
|
"mappings": "AAkCO,IAAI,EAAkB,CAAC,IAC5B,QAAQ,QAAQ,OAAO,OAAW,IAAc,OAAO,QAAQ,CAAO,EAAI,EAAI,EAIzE,SAAS,CAAkB,CAAC,EAAI,CACrC,EAAkB",
|
|
8
|
-
"debugId": "
|
|
8
|
+
"debugId": "B602C81CFFC471CC64756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|