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