phlex-reactive 0.4.8 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +924 -4
- data/README.md +986 -32
- data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
- data/app/javascript/phlex/reactive/compute.js +52 -7
- data/app/javascript/phlex/reactive/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/confirm.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +1487 -80
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
- data/lib/generators/phlex/reactive/component/USAGE +2 -1
- data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
- data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
- data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
- data/lib/phlex/reactive/component/dsl.rb +249 -0
- data/lib/phlex/reactive/component/helpers.rb +595 -0
- data/lib/phlex/reactive/component/identity.rb +92 -0
- data/lib/phlex/reactive/component/registry.rb +200 -0
- data/lib/phlex/reactive/component.rb +30 -442
- data/lib/phlex/reactive/doctor.rb +333 -0
- data/lib/phlex/reactive/engine.rb +27 -9
- data/lib/phlex/reactive/js.rb +249 -0
- data/lib/phlex/reactive/log_subscriber.rb +64 -0
- data/lib/phlex/reactive/param_schema.rb +390 -0
- data/lib/phlex/reactive/reply.rb +5 -3
- data/lib/phlex/reactive/response.rb +160 -16
- data/lib/phlex/reactive/stream.rb +98 -0
- data/lib/phlex/reactive/streamable.rb +307 -43
- data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
- data/lib/phlex/reactive/test_helpers.rb +308 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +333 -14
- data/lib/tasks/phlex_reactive.rake +14 -0
- metadata +19 -1
data/CHANGELOG.md
CHANGED
|
@@ -6,8 +6,870 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.9.0] - 2026-07-03
|
|
10
|
+
|
|
11
|
+
Consolidates everything since 0.2.6; tags 0.2.7–0.4.8 shipped without changelog
|
|
12
|
+
sections, so this section spans the whole run to 0.9.0. The jump from 0.4.8 to
|
|
13
|
+
0.9.0 signals the Tier-2 API wave (#79–#120) and the run-up to 1.0, leaving 0.9.x
|
|
14
|
+
room for dogfood fixes.
|
|
15
|
+
|
|
16
|
+
### Upgrading from 0.4.x
|
|
17
|
+
|
|
18
|
+
- **BREAKING — an unknown param-type symbol now raises at boot, not at click
|
|
19
|
+
time (#109).** A `params:` schema declared with a type the library doesn't know
|
|
20
|
+
(a typo like `:strng`, or a type you meant to register) raises
|
|
21
|
+
`Phlex::Reactive::UnknownParamType` when the component class loads — the schema
|
|
22
|
+
is compiled once at declaration. Fix the typo or register the type. Previously a
|
|
23
|
+
bad type symbol slipped through to the request and failed obscurely per click.
|
|
24
|
+
- **Minimum Ruby is now 3.4** (was 3.2). Enabling the `it` block parameter
|
|
25
|
+
requires 3.4, so `required_ruby_version` is `>= 3.4.0`. Stay on the 0.3.x line
|
|
26
|
+
if you need Ruby 3.2/3.3.
|
|
27
|
+
- **`optimistic:` / `loading:` / `disable_with:` are now RESERVED `on(...)`
|
|
28
|
+
keywords** (#98, #99), joining `window:` / `once:` / `outside:` / `throttle:`
|
|
29
|
+
from #80. If an action of yours took a free param literally named `optimistic`,
|
|
30
|
+
`loading`, or `disable_with`, rename it — those names now configure the
|
|
31
|
+
client-side hint/loading behavior instead of passing through as an action param.
|
|
32
|
+
- **`flash_builder` → `stream_builder` rename (#113) — no action needed.** The
|
|
33
|
+
builder does far more than flashes, so it was renamed;
|
|
34
|
+
`Phlex::Reactive.flash_builder` and `reset_flash_builder!` remain **permanent
|
|
35
|
+
aliases** (no deprecation). Listed only for grep-ability if you referenced the
|
|
36
|
+
old names.
|
|
37
|
+
- **The client runtime now ships pre-minified (#148) — zero change for importmap
|
|
38
|
+
consumers.** The engine pins the `.min.js` build (106 KB → 22 KB, ~7.7 KB
|
|
39
|
+
gzipped) with a linked sourcemap, so devtools still shows readable source. If
|
|
40
|
+
you vendor the client file by hand, re-vendor from the minified build (the
|
|
41
|
+
dummy app's `spec/dummy/public/vendor/reactive_controller.js` is that build).
|
|
42
|
+
|
|
43
|
+
### Security
|
|
44
|
+
|
|
45
|
+
- **The signed-token version (`v`) now fails closed on a malformed value.**
|
|
46
|
+
`upgrade_token` type-guards `v` before comparing it: a non-integer or negative
|
|
47
|
+
`v` (only producible with the signing key, or by operator error — `v` lives
|
|
48
|
+
inside the signed blob) now returns `nil` → `400` through the endpoint's
|
|
49
|
+
`|| raise(InvalidToken)`, instead of a 500 on the `v > TOKEN_VERSION`
|
|
50
|
+
comparison or silently treating a negative `v` as a legacy passthrough. The
|
|
51
|
+
fail-closed-on-rollback contract (a `v` newer than this code understands → 400)
|
|
52
|
+
is unchanged.
|
|
53
|
+
- **The raw-array `js([...])` / `broadcast_js_to([...])` escape hatch now
|
|
54
|
+
re-applies the attribute-name allowlist server-side.** The `JS` builder rejects
|
|
55
|
+
event-handler (`on*`), URL-bearing, and `style` attribute names at build time;
|
|
56
|
+
the raw `[[op, args], …]` form skipped that Ruby-side check and relied on the
|
|
57
|
+
client interpreter alone. `Phlex::Reactive::JS.assert_ops_allowed!` restores
|
|
58
|
+
full server-side parity (defense in depth — the client still enforces it too),
|
|
59
|
+
so a hand-built ops array can't emit an `onclick`/`href`/`style` attr op.
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
|
|
63
|
+
- **One inheritance-aware registry behind the Component DSL; `component.rb` split
|
|
64
|
+
into cohesive concerns (#115).** All five class-level registries
|
|
65
|
+
(`reactive_actions`, `reactive_state_keys`, `reactive_collections`,
|
|
66
|
+
`reactive_computes`, `reactive_record_key`) now resolve through
|
|
67
|
+
`Phlex::Reactive::Component::Registry` with ONE semantic:
|
|
68
|
+
resolve-through-superclass at read time, memoized per class against a
|
|
69
|
+
generation counter bumped on any registry write. **The one visible behavior
|
|
70
|
+
change** (previously divergent): a parent class declaring an action/state
|
|
71
|
+
key/collection/compute AFTER a subclass had been read is now visible to that
|
|
72
|
+
subclass — pre-#115 the four collection registries snapshot-dup'd the parent
|
|
73
|
+
on first access (the late declaration was silently invisible), while
|
|
74
|
+
`reactive_record_key` walked live; the hot-path identity memos could go stale
|
|
75
|
+
split-brain against the live key. All covered by the new registry-inheritance
|
|
76
|
+
contract suite. Zero public API change: every reader keeps its exact
|
|
77
|
+
signature, `reactive_compute_def(name)` is added as the reader form matching
|
|
78
|
+
`reactive_collection_def` (the `reactive_compute(name)` getter stays a
|
|
79
|
+
permanent alias), and the token hot path is measured byte-identical
|
|
80
|
+
(unchanged i/s within noise; identical allocations). `component.rb` is now an
|
|
81
|
+
aggregator over `component/{registry,dsl,identity,helpers}.rb` — pure code
|
|
82
|
+
motion, constant paths unchanged.
|
|
83
|
+
|
|
84
|
+
### Performance
|
|
85
|
+
|
|
86
|
+
- **Ship a minified client runtime — `reactive_controller.js` 106 KB → 22 KB
|
|
87
|
+
(−79%; ~7.7 KB gzipped).** The authored client modules are comment-dense on
|
|
88
|
+
purpose (the source is the documentation, and the JS suite imports it), so the
|
|
89
|
+
gem no longer ships that source to browsers. `rake build:js` (bun) produces a
|
|
90
|
+
minified twin of each module — `reactive_controller.min.js`,
|
|
91
|
+
`confirm.min.js`, `compute.min.js` — each with a linked sourcemap that
|
|
92
|
+
embeds the original source, so devtools still shows the readable code. The
|
|
93
|
+
engine now pins and precompiles the `.min.js` (plus its `.map`); the bare
|
|
94
|
+
specifiers are unchanged, so **no consumer edit is required** — importmap apps
|
|
95
|
+
transparently load the small file. The confirm/compute override seams stay
|
|
96
|
+
separately pinned (not bundled in), so `import { setConfirmResolver } from
|
|
97
|
+
"phlex/reactive/confirm"` still works. The bun minifier output is
|
|
98
|
+
deterministic (byte-identical across bun patch releases), so the artifacts are
|
|
99
|
+
committed and shipped in the gem — consumers need no bun — and CI (`rake
|
|
100
|
+
build:js_check`) rebuilds and fails on drift. The system suite now runs the
|
|
101
|
+
vendored minified build in a real browser under both Puma and Falcon, so the
|
|
102
|
+
code that ships is the code that is proven. Measured on bun 1.3.14.
|
|
103
|
+
- **Multi-key broadcast fan-out is ~9.5× faster (#119).** Measured, transport
|
|
104
|
+
doubled out, K=10 stream keys: a hand loop over `broadcast_replace_to` →
|
|
105
|
+
`broadcast_replace_to_each` moves 2.88k i/s (347 μs) → 27.3k i/s (37 μs)
|
|
106
|
+
(**9.5×**, and within ~4% of a single 1-key broadcast — K renders + K HMACs
|
|
107
|
+
collapse to 1 + 1), allocations 1250 obj / 186 KB → 151 obj / 22 KB (**−88%**
|
|
108
|
+
objects, 0 retained). Same-machine before/after on the new
|
|
109
|
+
`benchmark/micro/broadcast.rb`; numbers on the performance docs page.
|
|
110
|
+
- **`#extractToken` per-id regex memoization (#118).** The client's
|
|
111
|
+
`#extractToken` (`reactive_controller.js`) compiled its two self-matching
|
|
112
|
+
`RegExp` objects — the `reactive:token` matcher and the self replace/update
|
|
113
|
+
matcher — on **every** response, even though the root's `id` is page-stable.
|
|
114
|
+
They are now memoized on the instance keyed on `this.element.id`, rebuilt only
|
|
115
|
+
when the id changes (a re-render that re-identifies the root must scan for the
|
|
116
|
+
new target, never a stale one — covered by a new id-change bun test). The regex
|
|
117
|
+
**patterns are byte-identical**; only their allocation moved, so token
|
|
118
|
+
self-matching semantics are unchanged (the #44/#46/#47 pins stay green).
|
|
119
|
+
**Measured, not assumed** (`rake bench:client`, bun 1.3 / M2 Max): before/after
|
|
120
|
+
is within run-to-run noise (~4.4 µs on a 2 KB body, ~9–11 µs on a 500 KB 200-row
|
|
121
|
+
collection, both sides), because `#extractToken` is already **~0.25% of the
|
|
122
|
+
`DOMParser` ceiling** (~4 ms on that 500 KB body) — removing two allocations per
|
|
123
|
+
call sits below the timing floor of the dispatch-driven bench. It is a
|
|
124
|
+
correctness/cleanliness win on a hot path, **not** a throughput change; per the
|
|
125
|
+
issue's decision rule, recorded as "measured — not worth further optimization"
|
|
126
|
+
and closed.
|
|
127
|
+
|
|
9
128
|
### Added
|
|
10
129
|
|
|
130
|
+
- **`broadcast_*_to_each` — render-once, multi-key broadcast fan-out (#119).**
|
|
131
|
+
`broadcast_*_to` concatenates its `*streamables` into ONE stream key, so
|
|
132
|
+
pushing the same component to K DIFFERENT keys (a per-tenant loop — "the list
|
|
133
|
+
page stream AND the dashboard stream") with a hand-written loop was K builds +
|
|
134
|
+
K renders + K identity HMACs for BYTE-IDENTICAL HTML. Every verb now has an
|
|
135
|
+
`_each` sibling — `broadcast_replace_to_each` / `_update_` / `_append_` /
|
|
136
|
+
`_prepend_` / `_remove_` — taking an enumerable of stream keys (each a
|
|
137
|
+
`[record, :symbol]` pair or a bare string): it renders the component **once**
|
|
138
|
+
and loops only the cheap channel call. Transport opts (`exclude:` /
|
|
139
|
+
`visible_to:`) and `morph:` forward **per key** exactly as the single-key verbs
|
|
140
|
+
do, so the pgbus path suppresses the actor's echo on every stream and the
|
|
141
|
+
Action Cable path is unchanged (the no-opts call passes no unknown keyword —
|
|
142
|
+
the pgbus-optionality invariant holds; the old-pgbus-shape double guards the
|
|
143
|
+
`ArgumentError` regression). Per-VIEWER `visible_to:` content (different HTML
|
|
144
|
+
per viewer) stays the irreducible render-per-call case. Pure composition of the
|
|
145
|
+
existing private helpers; no change to any single-key method.
|
|
146
|
+
- **`benchmark/micro/broadcast.rb` — the missing broadcast bench (#119).** The
|
|
147
|
+
broadcast render path was a named hot path (`.claude/rules/performance.md`)
|
|
148
|
+
with no bench. It doubles the transport out and measures the server-side
|
|
149
|
+
build + render + HMAC cost at 1 key, a hand K-key loop, and `_to_each` over K
|
|
150
|
+
keys — auto-discovered by `rake bench:micro`. It also benched `model_param_name`
|
|
151
|
+
(the audit's measure-first candidate: 815k i/s, 8 obj/call — immaterial next to
|
|
152
|
+
the ~37 μs build, so **measured and left alone**, no memoization).
|
|
153
|
+
- **`benchmark/micro/verify.rb` — isolate token verify/sign cost; the
|
|
154
|
+
digest/serializer question is now measured and closed (#120).** `verify` runs
|
|
155
|
+
before anything else on every reactive request (a garbage-token flood pays it),
|
|
156
|
+
and `sign` runs once per rendered component (N× for an N-row collection) — yet
|
|
157
|
+
neither had ever been measured in isolation (`token.rb` covers sign-side
|
|
158
|
+
*assembly*, not the HMAC), and the default digest/serializer was never compared.
|
|
159
|
+
The new bench (auto-discovered by `rake bench:micro`, no harness change) reports
|
|
160
|
+
four verify cost classes — **valid** state/record (~136k/147k i/s, 20 obj), a
|
|
161
|
+
**tampered** token that pays the full constant-time HMAC compare (~203k i/s,
|
|
162
|
+
~6× slower than garbage), and a **garbage** flood that bails at format parsing
|
|
163
|
+
(~1.2M i/s, 4 obj) — plus `sign` ×1 (~155k i/s, 12 obj) and the **collection
|
|
164
|
+
cost** `sign` ×100 (~1.6k i/s, 1200 obj, linear). It also compares the app
|
|
165
|
+
default (SHA1, json+marshal) against SHA256 and a strict JSON serializer for
|
|
166
|
+
both verify and sign: **every variant falls within measurement noise**, so
|
|
167
|
+
phlex-reactive **ships no runtime change and no opt-in recipe** — the setter
|
|
168
|
+
(`Phlex::Reactive.verifier = …`) is documented as the escape hatch if your own
|
|
169
|
+
profile ever shows verify hot, but the data says it will not. Verification
|
|
170
|
+
semantics, purpose scoping, and default-deny are untouched — this issue
|
|
171
|
+
*measures*. Numbers and the "measured, closed" verdict are on the performance
|
|
172
|
+
page. Pure tooling + docs; no production-code change.
|
|
173
|
+
|
|
174
|
+
- **Client dispatch micro-bench harness — `rake bench:client` (#116).** The client
|
|
175
|
+
hot path (`app/javascript/.../reactive_controller.js`) was named a hot path in
|
|
176
|
+
`.claude/rules/performance.md` but had **no bench of any kind** — every claim
|
|
177
|
+
about `#extractToken` / `#collectFields` / `recompute` cost was a guess the
|
|
178
|
+
perf prime directive forbids, and it blocked the client-perf issues that need a
|
|
179
|
+
before/after. `benchmark/client/` now holds bun-runnable
|
|
180
|
+
[mitata](https://github.com/evanwashere/mitata) benches that drive those three
|
|
181
|
+
paths through the controller's **public surface only** (`dispatch()` /
|
|
182
|
+
`recompute()`) — **no `__bench` exports, nothing under `app/javascript/`
|
|
183
|
+
changes**, so the vendored-client re-sync rule is never tripped. `rake
|
|
184
|
+
bench:client` shells to `bun run benchmark/client/index.bench.js`, writes
|
|
185
|
+
`tmp/benchmarks/client.txt` alongside `micro.txt`, and **fails the task on a
|
|
186
|
+
crashed bench** (mitata runs with `throw: true`, matching the `bench:micro`
|
|
187
|
+
contract). New dev-only dependencies: **mitata + happy-dom** (a JS DOM engine
|
|
188
|
+
for the `collectFields`/`recompute` fixtures). Baselines are documented on the
|
|
189
|
+
performance page with honest framing: the happy-dom numbers are
|
|
190
|
+
**engine-relative** (valid for same-machine before/after deltas, not an
|
|
191
|
+
absolute browser cost), while the regex-only `#extractToken` numbers are
|
|
192
|
+
**engine-faithful** under bun/JSC. Pure tooling — no production-code change.
|
|
193
|
+
|
|
194
|
+
- **`morph:` on the update verbs (#113).** The `update` family now takes the same
|
|
195
|
+
`morph:` flag `replace` already had, closing the verb-matrix asymmetry: Turbo 8
|
|
196
|
+
supports `method="morph"` on `action="update"`, so an inner-HTML update can morph
|
|
197
|
+
in place instead of swapping. This matters most for a **cross-tab
|
|
198
|
+
`broadcast_update_to`** into a component a peer is editing — a plain update tore
|
|
199
|
+
down their focus/caret; `morph: true` patches the inner HTML in place and keeps
|
|
200
|
+
it. Threaded through every update surface:
|
|
201
|
+
- `Streamable.update(model, morph: true)` (class builder) and
|
|
202
|
+
`#to_stream_update(morph: true)` (instance primitive) — emit
|
|
203
|
+
`method="morph"`.
|
|
204
|
+
- `broadcast_update_to(*streamables, morph: true)` — carries the attr through
|
|
205
|
+
`attributes:` (the broadcast path has no `method:` kwarg), so it works on
|
|
206
|
+
**both** Action Cable and pgbus; the pgbus-absent fallback is unchanged.
|
|
207
|
+
- `Response.update(component, morph: true)` and `Reply#update(morph: true)` —
|
|
208
|
+
`reply.update(morph: true)`.
|
|
209
|
+
|
|
210
|
+
**Default `false` everywhere → byte-identical to today** (no `method=` attribute),
|
|
211
|
+
so existing components are unaffected. No new dependency — Idiomorph ships with
|
|
212
|
+
`turbo-rails >= 2.0`.
|
|
213
|
+
|
|
214
|
+
- **Component-aware `around_action` seam (#112).** Register a wrapper with
|
|
215
|
+
`Phlex::Reactive.around_action { |ctx, &action| … }` and it folds into the
|
|
216
|
+
endpoint **between** `with_connection_id` and the action's transaction — so it
|
|
217
|
+
sees the resolved component instance, the declared action name, and the
|
|
218
|
+
**coerced** params (a frozen `Phlex::Reactive::ActionContext`), and a rejection
|
|
219
|
+
never opens a transaction. This is the seam for audit logging, component-aware
|
|
220
|
+
rate limiting, and assertions; the base controller (`base_controller_name`)
|
|
221
|
+
stays the seam for HTTP-layer concerns (auth, CSRF, coarse per-IP throttling)
|
|
222
|
+
that don't need the resolved action. Contract: **each wrapper MUST return
|
|
223
|
+
`action.call`'s value** — the endpoint type-checks the action's return for a
|
|
224
|
+
`Phlex::Reactive::Response`, so a wrapper that returns its logger's result
|
|
225
|
+
silently downgrades every reply to the implicit self-replace. A wrapper raising
|
|
226
|
+
a registered `authorization_errors` error renders as 403; an unregistered raise
|
|
227
|
+
is a 500. Wrappers nest in registration order (last-registered outermost). The
|
|
228
|
+
empty stack (the default) adds only one `Array#empty?` check to the hot path;
|
|
229
|
+
`Phlex::Reactive.reset_around_actions!` resets it for tests. See the README
|
|
230
|
+
"Two seams" section and the security page.
|
|
231
|
+
|
|
232
|
+
- **Versioned identity-token payload + upgrader chain (#111).** Every signed
|
|
233
|
+
token now carries a `"v"` key (`Phlex::Reactive::TOKEN_VERSION`, currently `1`),
|
|
234
|
+
and `Phlex::Reactive.verify` runs the payload through an upgrader chain before
|
|
235
|
+
your component rebuilds from it. This is infrastructure for the NEXT breaking
|
|
236
|
+
shape change (a rename, per-token expiry, a nonce): instead of every open page
|
|
237
|
+
breaking at deploy — an old-shape token verifies fine, then blows up later in
|
|
238
|
+
`from_identity` — the old payload upgrades transparently. Contract:
|
|
239
|
+
- A token minted **before** versioning existed carries no `"v"`; it is read as
|
|
240
|
+
version 0 (today's shape) and passes through byte-identical — introducing
|
|
241
|
+
versioning **invalidated nothing in flight**.
|
|
242
|
+
- When you change the shape, bump `TOKEN_VERSION` and register
|
|
243
|
+
**`Phlex::Reactive.register_token_upgrader(old_version) { |payload| … }`**;
|
|
244
|
+
upgraders run oldest → current.
|
|
245
|
+
- A token from a **newer** version than the running code (a rolled-back deploy)
|
|
246
|
+
**fails closed** — `verify` returns `nil`, so the endpoint answers 400, the
|
|
247
|
+
same path as a tampered token. Never fail-open.
|
|
248
|
+
|
|
249
|
+
`from_identity` ignores the extra `"v"` key, so existing components are
|
|
250
|
+
unaffected. Perf: the one added key costs +3 objects / +480 bytes per
|
|
251
|
+
`reactive_token` (a single `Hash#merge`); HMAC signing dominates and is
|
|
252
|
+
unchanged, so throughput is within measurement noise.
|
|
253
|
+
|
|
254
|
+
- **Public `Phlex::Reactive::TestHelpers` + a no-HTTP `run_reactive` action
|
|
255
|
+
driver (#110).** The gem now ships the test surface it used to keep private.
|
|
256
|
+
Mix it in (`config.include Phlex::Reactive::TestHelpers`) for:
|
|
257
|
+
- **`run_reactive(component, :act, **params)`** — the no-HTTP unit driver. It
|
|
258
|
+
runs the action through the SAME security contract the endpoint enforces —
|
|
259
|
+
**default-deny** (an undeclared action raises
|
|
260
|
+
`Phlex::Reactive::TestHelpers::UndeclaredReactiveAction`), the **signed
|
|
261
|
+
identity round-trip** (a record-backed component's row is re-found; a deleted
|
|
262
|
+
record raises `ActiveRecord::RecordNotFound`, the endpoint's 404), **schema
|
|
263
|
+
coercion** (#109), and the same **transaction wrapper** — with no HTTP, and
|
|
264
|
+
returns a `Result`. A registered authorization error RAISES (the endpoint
|
|
265
|
+
maps it to 403). So a unit test can no longer pass on a component that would
|
|
266
|
+
fail a real click.
|
|
267
|
+
- **`Result`** — wraps the action's return: `replace?` / `remove?` /
|
|
268
|
+
`redirect?` / `redirect_url` / `streams` / `response`, plus `component` (the
|
|
269
|
+
instance rebuilt from identity — the one the action ran against). A legacy
|
|
270
|
+
action returning an arbitrary value reads as an implicit replace.
|
|
271
|
+
- **`reactive_token_for(component_or_class, payload = {})`** — mint a token the
|
|
272
|
+
way a component does (class form via the public `Phlex::Reactive.sign`;
|
|
273
|
+
instance form wraps the private `reactive_token`). No more
|
|
274
|
+
`component.send(:reactive_token)`.
|
|
275
|
+
- **`post_reactive_action` / `post_reactive_multipart`** — POST a signed token
|
|
276
|
+
to **`Phlex::Reactive.action_path`** (never a hardcoded `/reactive/actions`,
|
|
277
|
+
so a remounted path is honored) exactly as the client encodes it.
|
|
278
|
+
- **Matchers** `have_reactive_replace`, `have_reactive_remove`,
|
|
279
|
+
`have_reactive_token_for` (RSpec; loaded only when RSpec is present, so a
|
|
280
|
+
Minitest app asserts on `Result` predicates directly). The token matcher pins
|
|
281
|
+
the #44/#46 refresh regression class for adopters.
|
|
282
|
+
|
|
283
|
+
The gem's own request suite now runs THROUGH the public module (dogfooding),
|
|
284
|
+
and the component-generator spec template + the testing docs teach the public
|
|
285
|
+
API — every `send(:reactive_token)` instruction is gone.
|
|
286
|
+
|
|
287
|
+
- **`Phlex::Reactive::ParamSchema` — declaration-time validation + app-registerable
|
|
288
|
+
param types (#109).** The ~200 lines of param coercion moved out of
|
|
289
|
+
`ActionsController` into a standalone `Phlex::Reactive::ParamSchema`, **compiled
|
|
290
|
+
once** when you declare an action (`ParamSchema.compile(params)`) instead of
|
|
291
|
+
interpreted on every request. Two adopter-facing wins:
|
|
292
|
+
- **Typos fail loud, at boot.** A schema naming an unknown type symbol
|
|
293
|
+
(`params: { count: :interger }`) now raises
|
|
294
|
+
**`Phlex::Reactive::UnknownParamType`** (an `ArgumentError` subclass) at class
|
|
295
|
+
load — validated recursively through nested-hash and array-of-hash element
|
|
296
|
+
types — instead of silently coercing the value to a `String` at click time.
|
|
297
|
+
- **New built-in types + custom registration.** `:date`/`:datetime` (ISO8601,
|
|
298
|
+
dropped on a parse failure) and `:decimal` (`BigDecimal`, dropped on a
|
|
299
|
+
non-numeric value) join the built-ins, so a date/decimal param no longer needs
|
|
300
|
+
`:string` + hand-parsing. Register your own with
|
|
301
|
+
**`Phlex::Reactive.param_type(:money) { |v| … }`** in an initializer — return
|
|
302
|
+
`Phlex::Reactive::ParamSchema::DROP` to reject a value (the keyword default
|
|
303
|
+
then applies, keeping the drop-don't-fabricate contract). The registry is
|
|
304
|
+
frozen after boot, so registration is initializer-only.
|
|
305
|
+
|
|
306
|
+
Behavior is otherwise **byte-for-byte identical**: every existing built-in keeps
|
|
307
|
+
its exact semantics (`"abc".to_i → 0`, not a drop; the `:file` duck-type drop;
|
|
308
|
+
the array/hash drop rules; the `#16`/`#21`/`#24`/`#39` bracket-expansion and
|
|
309
|
+
deep-merge matrix), and the `verbose_errors` dropped-param collector (`#82`/`#87`)
|
|
310
|
+
threads through unchanged — a `nil` collector is still the zero-cost production
|
|
311
|
+
path. Coercion is now unit-tested directly (`spec/phlex/reactive/param_schema_spec.rb`)
|
|
312
|
+
in addition to the request-spec integration cover. Bench (`rake bench:one[coerce_params]`),
|
|
313
|
+
same machine: ~35.5k → ~36.0k i/s, 207 → 202 obj/call (the memoized `Boolean`
|
|
314
|
+
type replaces a per-call instantiation), 0 retained — unchanged within noise.
|
|
315
|
+
|
|
316
|
+
- **Client debug mode (devtools-lite) — `console.group` every dispatch (#108).**
|
|
317
|
+
The `LogSubscriber` (#107) is the *server* lens; this is the *client* one. Set
|
|
318
|
+
`Phlex::Reactive.debug = true` (default off; the initializer template suggests
|
|
319
|
+
`Rails.env.development?`) and `reactive_attrs`/`reactive_root` stamp
|
|
320
|
+
`data-reactive-debug="true"` on the root — the string `"true"`, not a
|
|
321
|
+
boolean-true attr, which Phlex renders valueless and the client would read as
|
|
322
|
+
falsy. The generic controller then `console.group`s **every dispatch** with the
|
|
323
|
+
action, the explicit param **names** + collected sibling-field **names** (never
|
|
324
|
+
their values — they may be sensitive), the request encoding (`json`/`multipart`),
|
|
325
|
+
the HTTP status, the response's stream actions + targets (parsed from the body
|
|
326
|
+
the controller **already read** — no re-fetch), whether a token refresh arrived
|
|
327
|
+
(a boolean — **never the token value**), and the round-trip ms:
|
|
328
|
+
`▼ reactive #todo_42 rename → 200 (48ms)`. This finally surfaces the
|
|
329
|
+
*successful-but-wrong* path (the #30/#44/#46/#50 token-threading series: which
|
|
330
|
+
streams arrived? did a refresh come?), which `console.error` + the #79 lifecycle
|
|
331
|
+
events never covered. **Zero cost when off** — one attribute check per dispatch,
|
|
332
|
+
no string building — measured on the render/token micro benches: **0 extra
|
|
333
|
+
allocations** per render and no throughput change. See the Client debug mode
|
|
334
|
+
section of the README.
|
|
335
|
+
|
|
336
|
+
- **`ActiveSupport::Notifications` on the hot paths + an opt-in `LogSubscriber`
|
|
337
|
+
(#107).** The gem now emits three events under the `phlex_reactive` namespace,
|
|
338
|
+
so an APM (AppSignal, Datadog, Skylight) sees reactive traffic at the
|
|
339
|
+
**component level** — which component/action a slow request was, render time,
|
|
340
|
+
and broadcast fan-out — where before it saw nothing:
|
|
341
|
+
- `action.phlex_reactive` — one per request; payload `component`, `action`,
|
|
342
|
+
`outcome` (`ok`/`denied_undeclared`/`invalid_token`/`not_found`/`unauthorized`).
|
|
343
|
+
- `render.phlex_reactive` — per render; payload `component`, `bytesize`.
|
|
344
|
+
- `broadcast.phlex_reactive` — per `broadcast_*_to` (fires on Action Cable
|
|
345
|
+
**and** pgbus); payload `component`, `stream_action`, `streamables` count.
|
|
346
|
+
|
|
347
|
+
Payloads carry **names, the outcome, and sizes only** — never the token,
|
|
348
|
+
params, or state, so an event can't leak a secret; an `invalid_token` event has
|
|
349
|
+
no trusted component name (the token didn't verify) and omits it. Statuses and
|
|
350
|
+
the endpoint flow are unchanged — the instrument only wraps the existing paths.
|
|
351
|
+
An unsubscribed `instrument` is cheap (a few objects per call, **zero
|
|
352
|
+
retained** — measured on the render micro bench) so the hot paths carry it
|
|
353
|
+
unconditionally. Flip on `Phlex::Reactive.log_events = true` (default off) to
|
|
354
|
+
get one compact debug line per event: `[reactive] Counter#increment ok (3.1ms)`.
|
|
355
|
+
See the Observability section of the README and the performance docs page.
|
|
356
|
+
|
|
357
|
+
- **`bin/rails phlex_reactive:doctor` — validate the whole install (#106).** Five
|
|
358
|
+
closed issues (#3 boot/eager-load, #26 route shadowing, #42 lost request, #48
|
|
359
|
+
unregistered controller, #57 importmap 404) were pure integration papercuts
|
|
360
|
+
that only surfaced **after** something already broke. The doctor turns "nothing
|
|
361
|
+
happens, why?" into an actionable checklist you run before or after setup. It
|
|
362
|
+
prints `✓/✗/?` with a fix for each failure and checks: the action route
|
|
363
|
+
resolves (reuses the boot route-shadow guard), the `reactive` controller is
|
|
364
|
+
registered in a Stimulus entrypoint (importmap **or** esbuild/bun **or** a
|
|
365
|
+
layout), `csrf_meta_tags` is referenced (**advisory `?`** — never a hard fail,
|
|
366
|
+
so a Phlex-only layout isn't false-flagged), the identity verifier round-trips,
|
|
367
|
+
`base_controller_name` constantizes, every declared `action :name` has a public
|
|
368
|
+
method (mirrors the endpoint's `public_send`), and every component resolves a
|
|
369
|
+
stable `#id` (a state-backed class with no `#id` is flagged; a record-backed
|
|
370
|
+
class on the #81 default is fine). It is **read-only** — no client surface, no
|
|
371
|
+
component instantiation, default-deny untouched — and exits non-zero on a hard
|
|
372
|
+
failure so CI or a setup script can gate on it.
|
|
373
|
+
- **Install generator hardening (#106).** The generator now also detects
|
|
374
|
+
`app/javascript/application.js` (esbuild/bun/webpack) as a Stimulus entrypoint,
|
|
375
|
+
not just the importmap-style `controllers/index.js`, and its post-install output
|
|
376
|
+
ends by telling you to run `bin/rails phlex_reactive:doctor` to verify.
|
|
377
|
+
|
|
378
|
+
### Performance
|
|
379
|
+
|
|
380
|
+
- **Nested-root fast path for the client field walks (#117).** `#collectFields`,
|
|
381
|
+
`recompute`, and `#listnavOptions` computed field ownership (the issue #15
|
|
382
|
+
"is this element mine, not a nested reactive root's?" check) with a per-element
|
|
383
|
+
`el.closest('[data-controller~="reactive"]')` walk on every matched field —
|
|
384
|
+
`recompute`'s `#ownedField` did a **fresh** `querySelectorAll('[name="…"]')` +
|
|
385
|
+
`closest()` filter **per declared input AND per output on every keystroke**
|
|
386
|
+
(~60 DOM queries on a 30-field calculator). A new `#ownershipFilter()` hoists
|
|
387
|
+
the decision to **once per op**: with no nested reactive root (the common case)
|
|
388
|
+
it returns a constant-true predicate and the per-field `closest()` walk is
|
|
389
|
+
skipped entirely; when a nested root is present it falls back to the exact
|
|
390
|
+
`#ownsField` closest() check, so issue #15 scoping is byte-identical. `recompute`
|
|
391
|
+
additionally resolves its inputs and outputs through a per-call, **first-wins**
|
|
392
|
+
`byName` memo (`if (owns(el)) break` on the first owned match — last-wins would
|
|
393
|
+
change radio-group / Rails hidden+checkbox behavior). Measured same-machine
|
|
394
|
+
before/after (bun + happy-dom, engine-relative): **recompute ~31 µs → ~23 µs per
|
|
395
|
+
keystroke (~25%)** on the 30-input calculator, 0 retained bytes/call;
|
|
396
|
+
`#collectFields` (once per dispatch, dominated by the dispatch overhead) stays
|
|
397
|
+
within noise. A **method/keystroke-level** win, not a request-level one. Field
|
|
398
|
+
ownership is unchanged for every component; the issue #15 nested-root bun tests
|
|
399
|
+
pass untouched. New coverage: `spec/javascript/reactive_recompute_ownership.test.js`
|
|
400
|
+
proves first-wins resolution for duplicate owned names and nested-root rejection.
|
|
401
|
+
|
|
402
|
+
### Changed
|
|
403
|
+
|
|
404
|
+
- **Structured `Phlex::Reactive::Stream` value object — the endpoint stops
|
|
405
|
+
regex-sniffing its own generated turbo-stream HTML (#114).** The action endpoint
|
|
406
|
+
used to reverse-engineer stream *semantics* out of *markup*: it substring-scanned
|
|
407
|
+
every stream for `data-reactive-token-value` and regexed the opening
|
|
408
|
+
`<turbo-stream>` tag's `action="…"` against a self-render allowlist to decide
|
|
409
|
+
whether a stream already refreshed the component's signed token. The gem was
|
|
410
|
+
parsing strings it had just built, and every new stream shape needed a new patch
|
|
411
|
+
to the heuristics. Now every `to_stream_*` instance primitive and every
|
|
412
|
+
`Streamable` class builder (`replace`/`update`/`append`/`prepend`/`remove`)
|
|
413
|
+
returns a `Phlex::Reactive::Stream` — an `ActiveSupport::SafeBuffer` subclass
|
|
414
|
+
that IS an html_safe String (so it drops into `render turbo_stream:`, ERB/Phlex
|
|
415
|
+
interpolation, and Turbo test-helper substring asserts unchanged) but also
|
|
416
|
+
carries `rx_action` / `rx_target` / `rx_renders_root?` set **structurally at
|
|
417
|
+
construction** plus `rx_carries_token?` computed **once from ground truth** (a
|
|
418
|
+
build-time scan of the actual bytes, never inferred from the builder kind — the
|
|
419
|
+
cosmos#1939 guard). The endpoint reads those fields instead of regexing markup;
|
|
420
|
+
`renders_root: false` on `append`/`prepend` encodes the #44 lesson (a child row's
|
|
421
|
+
own token in an append `<template>` can never count as the container's refresh)
|
|
422
|
+
in the *type*, not a regex. Raw strings (`reply.with(…)`, interpolated or
|
|
423
|
+
`gsub`'d streams) keep the pre-#114 regex path as a permanent fallback, gated on
|
|
424
|
+
`stream.is_a?(Stream)`. **The wire format is byte-identical** — same
|
|
425
|
+
token-refresh decisions (#30/#44/#46/#50 pins all green), different mechanism.
|
|
426
|
+
This is an architecture/correctness win, not a speedup: `rake bench:request`
|
|
427
|
+
before/after moves within measurement noise (allocations +1 obj/req state-backed,
|
|
428
|
+
+5 obj/req record-backed; throughput swings inside the ±15–28% error bars).
|
|
429
|
+
|
|
430
|
+
- **`Phlex::Reactive.flash_builder` → `stream_builder` (and `reset_flash_builder!`
|
|
431
|
+
→ `reset_stream_builder!`) (#113).** The memoized `Turbo::Streams::TagBuilder`
|
|
432
|
+
is used well beyond flashes — `reactive_collection` row removal, count-companion
|
|
433
|
+
updates, `also_update` — so the `flash_` name misled. Renamed to `stream_builder`
|
|
434
|
+
/ `reset_stream_builder!`. **`flash_builder` and `reset_flash_builder!` stay as
|
|
435
|
+
permanent aliases** (no deprecation warnings), so the engine's reload hook and
|
|
436
|
+
any app code keep working unchanged. Purely a rename — behavior is identical.
|
|
437
|
+
|
|
438
|
+
- **BREAKING (declaration-time): an unknown param type symbol now raises (#109).**
|
|
439
|
+
Before, a schema naming a type the coercer didn't recognize (a typo like
|
|
440
|
+
`params: { count: :interger }`, or a type never registered) fell through to a
|
|
441
|
+
silent `String` coercion. Now `Component.action` compiles the schema through
|
|
442
|
+
`Phlex::Reactive::ParamSchema.compile` and raises
|
|
443
|
+
**`Phlex::Reactive::UnknownParamType`** (an `ArgumentError` subclass) at class
|
|
444
|
+
load. The fix is to correct the typo or register the type
|
|
445
|
+
(`Phlex::Reactive.param_type(:name) { |v| … }` in an initializer). This only
|
|
446
|
+
affects schemas that were *already* silently mis-coercing; a valid schema is
|
|
447
|
+
unchanged.
|
|
448
|
+
|
|
449
|
+
- **`on(:typo)` fails at render, not click (#105).** A misspelled or forgotten
|
|
450
|
+
action used to render fine and only surface as an unexplained **403 on click**
|
|
451
|
+
(the endpoint's default-deny). Now, when **`Phlex::Reactive.verbose_errors`** is
|
|
452
|
+
on (the default in development and test via `Rails.env.local?`), **`on(:name)`
|
|
453
|
+
raises `Phlex::Reactive::Error`** at **render** time if `:name` isn't declared
|
|
454
|
+
on that component — the message lists the declared actions, the same loud-
|
|
455
|
+
failure courtesy `reactive_compute_attrs` already gives an undeclared compute.
|
|
456
|
+
You catch the typo the moment you load the page instead of hunting a mystery
|
|
457
|
+
403.
|
|
458
|
+
- **Production is unchanged.** With `verbose_errors` off (the production
|
|
459
|
+
default) `on()` keeps today's permissive emit, so a stale page after a deploy
|
|
460
|
+
that removed an action **never 500s on render**.
|
|
461
|
+
- **Cross-component dispatch still works.** A component that declares **no
|
|
462
|
+
actions of its own** — a child row rendering a trigger for its container's
|
|
463
|
+
action and sending the container's token (e.g. a notification row → the list's
|
|
464
|
+
`:dismiss`) — is skipped: it can't self-validate against a registry it doesn't
|
|
465
|
+
own. `on_client` triggers are never checked (they aren't declared actions).
|
|
466
|
+
- **Not the security boundary.** This is a dev-time aid; the server's
|
|
467
|
+
default-deny stays the enforcement. The check is one flag-gated hash lookup
|
|
468
|
+
with **zero added allocations** on the `on()`/render/token hot paths (measured:
|
|
469
|
+
`on(:increment)` and `to_stream_replace` allocate byte-identically before and
|
|
470
|
+
after; the prod path short-circuits on the flag before the lookup).
|
|
471
|
+
|
|
472
|
+
### Added
|
|
473
|
+
|
|
474
|
+
- **`reactive_text` mirrors + typed compute inputs (#104).** Mirror a form field
|
|
475
|
+
into a **text node** — a live preview heading, a character counter,
|
|
476
|
+
`"Hello, {name}"` — with **no round trip and no bespoke Stimulus controller**.
|
|
477
|
+
Datastar's `data-text` / Alpine's `x-text`, but declared on the component.
|
|
478
|
+
- **`reactive_text(:name, initial)`** renders `span(data: { reactive_text:
|
|
479
|
+
name }) { initial }` — the **text sibling of `reactive_field`**. The client
|
|
480
|
+
writes it via **`textContent`** (XSS-safe by construction, never `innerHTML`).
|
|
481
|
+
It carries **no `name`**, so `#collectFields` never sweeps it into the POSTed
|
|
482
|
+
params.
|
|
483
|
+
- **Typed compute inputs.** `reactive_compute :x, inputs: { title: :string,
|
|
484
|
+
qty: :number }, outputs:` types each input: a `:number` is coerced through
|
|
485
|
+
`Number` (blank/NaN → 0), a `:string` reaches the reducer **raw** — so a live
|
|
486
|
+
text preview reads real text, not `NaN`. The **array form** (`inputs: %i[a
|
|
487
|
+
b]`) stays all-numeric and **byte-identical on the wire** (a JSON array); the
|
|
488
|
+
hash form emits a JSON object of name→type.
|
|
489
|
+
- **Output resolution.** A compute output whose name matches an owned form
|
|
490
|
+
field writes that field's `.value` (the existing change-guarded `input`
|
|
491
|
+
dispatch). An output with **no** matching field writes every owned
|
|
492
|
+
`[data-reactive-text="<name>"]` node via `textContent` — change-guarded too,
|
|
493
|
+
but **no** input dispatch (a text node has no listener contract).
|
|
494
|
+
- **Reducer-less identity mirrors.** A separate always-run pass syncs each
|
|
495
|
+
declared **input**'s raw value into its own `reactive_text(:same_name)` node
|
|
496
|
+
on every keystroke — so `reactive_text(:title)` is a live field echo with **no
|
|
497
|
+
registered reducer at all**.
|
|
498
|
+
- **Seed the server render.** `view_template` must seed each mirror with the
|
|
499
|
+
same derived value the reducer would, or a later morph repaints stale text —
|
|
500
|
+
the same reconcile contract the new-vs-persisted split already documents.
|
|
501
|
+
|
|
502
|
+
- **Dirty-field tracking + unsaved-changes guard (#103).** Show "unsaved
|
|
503
|
+
changes", enable **Save** only when something changed, or warn before navigating
|
|
504
|
+
away — Livewire's `wire:dirty` — **without shipping any client state**. The
|
|
505
|
+
browser already holds the last server-rendered value with zero extra bytes:
|
|
506
|
+
`input.defaultValue` / `defaultChecked` / `option.defaultSelected` **are** the
|
|
507
|
+
attributes from the last render, so **dirty = current ≠ default**, read straight
|
|
508
|
+
from the DOM.
|
|
509
|
+
- **`reactive_root(track_dirty: true)`** wires every input under the root to a
|
|
510
|
+
full re-scan on change; **`reactive_field(:title, dirty: true)`** opts a single
|
|
511
|
+
field in. On each change the client re-scans **every owned field in one pass**
|
|
512
|
+
(the ownership guard excludes nested reactive roots) and marks each changed
|
|
513
|
+
field `data-reactive-dirty="true"` and the root `data-reactive-dirty="<count>"`
|
|
514
|
+
(**absent at zero**). The full pass — not a per-field toggle — is required for
|
|
515
|
+
radio groups: the deselected radio fires no event, so only re-scanning
|
|
516
|
+
everything keeps its flag honest. File inputs and `contenteditable` editors
|
|
517
|
+
(no `default*` baseline) are out of scope in v1.
|
|
518
|
+
- **CSS vocabulary, zero Ruby.** `[data-reactive-dirty] .unsaved-badge { … }`
|
|
519
|
+
reveals a badge; `[data-reactive-dirty]` on a field outlines just the changed
|
|
520
|
+
control — the same pure-CSS pattern as `[data-reactive-busy]`.
|
|
521
|
+
- **Baselines reset on the server re-render.** A plain replace re-connects and
|
|
522
|
+
re-scans in `connect()`; an in-place morph keeps the element connected and
|
|
523
|
+
fires no Stimulus lifecycle, so a `turbo:morph-element` listener re-scans after
|
|
524
|
+
the morph writes fresh `default*` attributes — a `reply.morph` save clears the
|
|
525
|
+
markers with no reload. (`reactive:applied` fires *before* the DOM mutation, so
|
|
526
|
+
it is **not** a valid reset hook and is deliberately not used.)
|
|
527
|
+
- **`warn_unsaved: true`** arms a navigate-away guard gated on the **live** dirty
|
|
528
|
+
count — `beforeunload` and `turbo:before-visit`; a clean form never blocks.
|
|
529
|
+
Documented caveats: browsers show their own generic `beforeunload` copy, and
|
|
530
|
+
`turbo:before-visit` does not fire on restoration (Back/Forward) visits.
|
|
531
|
+
- Both `reactive_root` kwargs are **consumed before the `mix`** (otherwise an
|
|
532
|
+
unconsumed kwarg would render as a literal HTML attribute); `track_dirty` and
|
|
533
|
+
`dirty:` **token-join** their `input->reactive#trackDirty` descriptor onto any
|
|
534
|
+
existing `data-action` (they don't clobber it), so combining with your own
|
|
535
|
+
`data:`/`on(...)` still needs `mix(...)`.
|
|
536
|
+
|
|
537
|
+
- **Latency simulator dev aid — `PhlexReactive.enableLatencySim(ms)` /
|
|
538
|
+
`disableLatencySim()` (#102).** On localhost the click→morph round trip is
|
|
539
|
+
~5 ms, so the pending/loading/optimistic affordances added in #98/#99/#100
|
|
540
|
+
(`aria-busy`, `disable_with:`, `busy_on`, optimistic hints) flash by too fast to
|
|
541
|
+
see while developing or demoing them — the reason LiveView ships
|
|
542
|
+
`liveSocket.enableLatencySim(ms)`. Two named exports from the client controller
|
|
543
|
+
(the `setConfirmResolver` precedent) persist a per-action delay to
|
|
544
|
+
`sessionStorage` under `"phlex-reactive:latency"`; `#perform` reads it live
|
|
545
|
+
right before the `fetch` — after the busy window has already opened at enqueue —
|
|
546
|
+
and awaits it, so the affordances become observable. The delay is session-scoped
|
|
547
|
+
(clears when the tab closes, so you can't leave it on across sessions), read
|
|
548
|
+
fresh per request (toggling takes effect on the next action with no reload), and
|
|
549
|
+
a one-time console banner reminds you while it's active.
|
|
550
|
+
- **Console handle, dev-gated.** importmap module exports are unreachable from
|
|
551
|
+
the DevTools console, so the bootstrap attaches
|
|
552
|
+
`window.PhlexReactive = { enableLatencySim, disableLatencySim }` — but **only**
|
|
553
|
+
when the app authored `<meta name="phlex-reactive-env" content="development">`.
|
|
554
|
+
The meta is app-authored (the engine can't inject into the host layout); the
|
|
555
|
+
install generator's initializer ships the commented snippet, and the README
|
|
556
|
+
documents it. Without the meta there is **no global handle** and `#perform`
|
|
557
|
+
short-circuits on the `null` `sessionStorage` read — **zero production
|
|
558
|
+
surface**. This finally lets a system spec observe `aria-busy` in a real
|
|
559
|
+
browser (previously impossible on a ~5 ms trip), under both Puma and Falcon.
|
|
560
|
+
|
|
561
|
+
- **Request timeout + offline handling — `reactive:error` kinds `timeout` and
|
|
562
|
+
`offline` (#101).** A server that never responded used to wedge a component's
|
|
563
|
+
request queue *forever* — each action chains on the previous one, so one hung
|
|
564
|
+
`fetch` froze every future action and the `finally` that clears `aria-busy` /
|
|
565
|
+
the loading state never ran. Going offline just fail-fast-looped each click as
|
|
566
|
+
`kind: "network"`. Two mechanisms close this:
|
|
567
|
+
- **Timeout.** The fetch is bounded by `AbortSignal.timeout(ms)` — default
|
|
568
|
+
**30 s**, configurable via a page-authored `<meta name="phlex-reactive-timeout">`
|
|
569
|
+
(same pattern as the action-path meta; no server setting). On abort it fires
|
|
570
|
+
`reactive:error` `kind: "timeout"` (retriable) and the queue advances, so the
|
|
571
|
+
component recovers. `AbortSignal.timeout()` rejects with a `TimeoutError`
|
|
572
|
+
`DOMException` (NOT `AbortError`), correctly distinguished from a genuine
|
|
573
|
+
network `TypeError` in the fetch catch.
|
|
574
|
+
- **Offline.** A gate at the **network boundary** (`#perform`, send time — so a
|
|
575
|
+
request that enqueued while online but reaches the wire after a drop is still
|
|
576
|
+
caught) short-circuits when `navigator.onLine === false`: it fires
|
|
577
|
+
`reactive:error` `kind: "offline"` (retriable) and never sends, so the edit is
|
|
578
|
+
not half-sent. `data-reactive-offline` is mirrored onto `<html>` (kept in sync
|
|
579
|
+
by the `online`/`offline` events) as a pure-CSS hook, zero app JS.
|
|
580
|
+
- **Explicit non-goal:** no automatic replay. A timed-out POST may have
|
|
581
|
+
succeeded server-side, so even manual `retry()` can double-apply a
|
|
582
|
+
non-idempotent action — make retryable actions idempotent or gate retry UI.
|
|
583
|
+
- **User-visible failure surface — render error bodies, `error_flash`,
|
|
584
|
+
`dismiss_after:` (#100).** A failing action used to show the user *nothing*:
|
|
585
|
+
the client read a non-OK body only for the console and discarded it, the
|
|
586
|
+
endpoint's rescue paths could log but not display anything, a network failure
|
|
587
|
+
had no server to render, and flashes never cleaned themselves up. Four pieces
|
|
588
|
+
close the gap, all opt-in and status-preserving:
|
|
589
|
+
- **Client renders non-OK turbo-stream bodies.** When `!response.ok` but the
|
|
590
|
+
Content-Type is a turbo-stream, the body is now applied (an `error_flash`, or
|
|
591
|
+
a plain controller's `status: :unprocessable_entity` validation reply, is
|
|
592
|
+
SHOWN). The root gets `data-reactive-error="<kind>"` (styleable in pure CSS),
|
|
593
|
+
cleared on the next success. Token safety is preserved: a 400 body never
|
|
594
|
+
refreshes the held identity token (`#extractToken` no-ops unless a stream
|
|
595
|
+
re-renders this element's id).
|
|
596
|
+
- **`Phlex::Reactive.error_flash`** (default `nil`) — a `->(kind) { "message" }`
|
|
597
|
+
lambda. When set, every rescue path (400/403/404) renders a turbo-stream flash
|
|
598
|
+
into `flash_target` at the **same status** it returns today. Composes with
|
|
599
|
+
`verbose_errors`: the flash wins the response body, the diagnostic still logs.
|
|
600
|
+
A lambda that raises degrades gracefully (falls back to the bare/diagnostic
|
|
601
|
+
body — never a 500).
|
|
602
|
+
- **Offline fallback** — a server-rendered `<template data-reactive-error-flash>`
|
|
603
|
+
the client clones into the flash region on a `network` failure (trusted
|
|
604
|
+
markup, cloned verbatim — no client templating).
|
|
605
|
+
- **`dismiss_after:` on `reply.flash`** — `reply.replace.flash(:error, msg,
|
|
606
|
+
dismiss_after: 4000)` self-removes the flash after the timeout via a
|
|
607
|
+
**document-level** handler (so it self-cleans broadcast-delivered flashes too).
|
|
608
|
+
Wraps string content; a verbatim Phlex component owns its own lifecycle.
|
|
609
|
+
- **Declarative loading states — `loading:` / `disable_with:` on `on(...)` +
|
|
610
|
+
`busy_on(:action)` (#99).** Between the click and the morph the UI was fully
|
|
611
|
+
live and unchanged: no per-trigger feedback, buttons stayed enabled, and a
|
|
612
|
+
rapid double-click enqueued a full duplicate POST (the queue serializes tokens,
|
|
613
|
+
it does not dedupe). This adds Livewire's `wire:loading` + `phx-disable-with`
|
|
614
|
+
without a Stimulus controller. `disable_with: "Saving…"` disables the trigger
|
|
615
|
+
and swaps its text while pending — and because a disabled button fires no
|
|
616
|
+
further clicks, a double-click now enqueues exactly **one** POST (the disable
|
|
617
|
+
is the dedup). `loading: { disable:, class:, text:, to: }` is the full form:
|
|
618
|
+
a loading class on the trigger or a `to:` target, plus disable + text. Both
|
|
619
|
+
apply at **enqueue** (covering the queue wait, not just the fetch) and revert
|
|
620
|
+
on settle — **guarded** so a disconnected trigger is skipped and a
|
|
621
|
+
server-rendered new label is never clobbered; the disable/text swap deliberately
|
|
622
|
+
do NOT apply during a `debounce:` quiet period, so a debounced `input` is not
|
|
623
|
+
disabled mid-typing. Independently, **every** round trip now carries an
|
|
624
|
+
always-on busy vocabulary for the whole enqueue→settle window — no Ruby needed:
|
|
625
|
+
`data-reactive-busy="<action>"` on the trigger and root (a **space-separated
|
|
626
|
+
set** on the root so concurrent actions don't clobber), `aria-busy` on the root
|
|
627
|
+
(via a **pending counter**, cleared only when the last request settles), and
|
|
628
|
+
`busy_on(:action)` to scope `data-reactive-busy` onto a spinner only while that
|
|
629
|
+
action is in flight. Style it with pure CSS (`[data-reactive-busy] .spinner { … }`).
|
|
630
|
+
The trigger is now captured from `event.currentTarget` (not `event.target`), so
|
|
631
|
+
a `<button><span>` click disables/relabels the button, not the inner span.
|
|
632
|
+
Emitted as `data-reactive-loading-param` (JSON) via the guarded-append pattern,
|
|
633
|
+
so the bare-`on()` hot path stays byte-identical. **`loading:` and
|
|
634
|
+
`disable_with:` are now RESERVED keyword names on `on(...)`** — like
|
|
635
|
+
`debounce:`/`confirm:`/`throttle:`/`optimistic:`, they can no longer be used as
|
|
636
|
+
free action params.
|
|
637
|
+
- **`optimistic:` on `on(...)` — declared, reversible visual hints (#98).**
|
|
638
|
+
Reactive interactions no longer wait a full round trip for their first visual
|
|
639
|
+
change — and a checkbox no longer fails to flip at all (the client's
|
|
640
|
+
unconditional `preventDefault` used to suppress the native flip until the
|
|
641
|
+
morph). `optimistic:` applies a small, always-reversible, **cosmetic**
|
|
642
|
+
vocabulary the instant the trigger fires and **reverts** it if the action
|
|
643
|
+
fails — Livewire's "flip it client-side, let the morph correct". Supported
|
|
644
|
+
hints: `toggle_class:`/`add_class:`/`remove_class:` (on the trigger, or a `to:`
|
|
645
|
+
selector scoped to the root), `checked: :keep` (a click-bound checkbox/radio
|
|
646
|
+
skips `preventDefault` so it flips natively now), and `hide: true`. Hints are
|
|
647
|
+
visual only (never data, never client state), applied **once per flushed
|
|
648
|
+
enqueue** (a debounced trigger can't flap per keystroke), and reverted from
|
|
649
|
+
every failure branch (redirected/http/content-type/network + client apply),
|
|
650
|
+
guarded by `isConnected`. On **success there is no cleanup**: a root re-render
|
|
651
|
+
overwrites the hint with server truth, while a reply that does not re-render
|
|
652
|
+
the root (`reply.remove`, streams-only) leaves it standing — that's the
|
|
653
|
+
`hide: true` + `reply.remove` instant-delete recipe. Emitted as
|
|
654
|
+
`data-reactive-optimistic-param` (JSON) following the guarded-append pattern of
|
|
655
|
+
`debounce:`/`confirm:`/`throttle:`, so the bare-`on()` hot path stays
|
|
656
|
+
byte-identical. **`optimistic:` is now a RESERVED keyword name on `on(...)`** —
|
|
657
|
+
like `debounce:`/`confirm:`/`throttle:`/`listnav:`, it can no longer be used as
|
|
658
|
+
a free action param.
|
|
659
|
+
- **`on_client(event, ops)` + the `js` op builder — client-side DOM commands
|
|
660
|
+
with ZERO round trips (#95).** Purely-visual interactions (tabs, dropdowns,
|
|
661
|
+
accordions, class toggles) no longer cost a signed server round trip or a
|
|
662
|
+
hand-written Stimulus controller. `js` is an immutable, chainable builder of
|
|
663
|
+
declared DOM operations — `show`/`hide`/`toggle` (the `hidden` attribute) and
|
|
664
|
+
`add_class`/`remove_class`/`toggle_class` — and `on_client(:click, js.…)`
|
|
665
|
+
binds them to a DOM event the ONE generic controller applies locally via a
|
|
666
|
+
new `runOps` action: **no token, no params, no fetch, ever** (the system spec
|
|
667
|
+
wraps `window.fetch` with a counter and asserts zero). The op vocabulary is a
|
|
668
|
+
fixed whitelist mirrored client-side: an unknown op name warns and is skipped
|
|
669
|
+
while the rest of the chain still applies (client-side default-deny —
|
|
670
|
+
`Object.hasOwn`-guarded so inherited Object members can't masquerade as ops),
|
|
671
|
+
and malformed ops JSON degrades to a no-op. Targets are CSS selectors
|
|
672
|
+
resolved WITHIN the component's root — nested reactive roots are never
|
|
673
|
+
touched (the issue-#15 ownership rule) — with `:root` for the root element
|
|
674
|
+
itself and a per-op `global: true` document escape. `window:`/`once:`/
|
|
675
|
+
`outside:` compose exactly like `on(...)`'s #80 modifiers (outside-click
|
|
676
|
+
closes a dropdown; window-bound triggers never `preventDefault`). Builder
|
|
677
|
+
validation is loud at render time (a non-selector target or an empty class
|
|
678
|
+
list raises; `on_client` rejects a non-`Phlex::Reactive::JS` or empty chain)
|
|
679
|
+
rather than silent in the browser. Client ops are EPHEMERAL UI by design: any
|
|
680
|
+
server re-render resets them — the LiveView JS-commands caveat, documented in
|
|
681
|
+
the README — so state that must survive a re-render stays a signed `action`.
|
|
682
|
+
Same-machine `rake bench` before/after: the token/render/coerce hot paths are
|
|
683
|
+
untouched (state-backed token 201k → within noise; allocations byte-identical
|
|
684
|
+
at 11/47 per token, 113 per render) — `on_client` is a new, separate path.
|
|
685
|
+
- **`js` client ops: allowlisted attribute ops, focus, dispatch, and animated
|
|
686
|
+
transitions (#96).** The client-only vocabulary from #95 grows to cover the
|
|
687
|
+
interactions apps otherwise fall back to hand-written Stimulus for. New builder
|
|
688
|
+
verbs: `set_attr(to, name, value)` / `remove_attr(to, name)` /
|
|
689
|
+
`toggle_attr(to, name)` for attribute state (the value rides as a string, so a
|
|
690
|
+
boolean flag is a real `"true"`, never a valueless attribute); `focus(to)` and
|
|
691
|
+
`focus_first(to)` (the first focusable descendant of the match — the opened-menu
|
|
692
|
+
→ first-menuitem case); and `dispatch(name, to: nil, detail: {})`, a **bubbling
|
|
693
|
+
`CustomEvent`** other components/controllers can react to (raw
|
|
694
|
+
`element.dispatchEvent` — the shared controller SHADOWS Stimulus's `this.dispatch`
|
|
695
|
+
helper). `show`/`hide`/`toggle` gain a `transition: [during, from, to]` keyword:
|
|
696
|
+
the class lists are applied around the visibility flip and cleaned up on
|
|
697
|
+
`animationend`, with a `setTimeout` fallback so a non-animated element never
|
|
698
|
+
leaves them stuck and never hangs the chain (later ops run immediately —
|
|
699
|
+
cleanup is fire-and-forget). The **attribute-name allowlist is the
|
|
700
|
+
security-critical part, enforced on BOTH sides (two-sided default-deny)**: at
|
|
701
|
+
build time in `js.rb` (an offending name raises `ArgumentError`) and again in the
|
|
702
|
+
client interpreter (a forged/hand-built op is `console.warn`-ed and skipped, so
|
|
703
|
+
it can't bypass the Ruby guard). Refused, case-insensitively: event handlers
|
|
704
|
+
(`/\Aon/i` → XSS), the URL-bearing set (`href`, `src`, `srcdoc`, `action`,
|
|
705
|
+
`formaction`, `xlink:href` → a `javascript:` navigation surface), and `style`
|
|
706
|
+
(CSS injection). The intended surface — class ops plus `hidden`, `disabled`,
|
|
707
|
+
`open`, `selected`, `aria-*`, `data-*` — is documented in the README. Covered by
|
|
708
|
+
Ruby specs (each verb's JSON shape; the allowlist raises for every refused name,
|
|
709
|
+
case-insensitively), bun tests (attr ops apply; the interpret-time allowlist
|
|
710
|
+
warns + skips a forged op; focus lands; `dispatch` emits a bubbling event with
|
|
711
|
+
detail; the transition swaps `from`→`to` on the next frame and cleans up on both
|
|
712
|
+
`animationend` and the timeout fallback), and a system spec that opens a drawer
|
|
713
|
+
with a real fade, sets `aria-expanded`, and focuses the first control under Puma
|
|
714
|
+
AND Falcon. Refs #96.
|
|
715
|
+
- **`reply.js(...)` + `broadcast_js_to` — server-pushed DOM ops over a
|
|
716
|
+
`reactive:js` stream action (#97).** The server can now tell the client to do
|
|
717
|
+
something other than swap HTML — focus the next field after a save, dispatch an
|
|
718
|
+
app event to a toast host, add an unread badge in every viewer's tab — WITHOUT
|
|
719
|
+
re-rendering to make it happen. `Response#js(ops)` (surfaced on the reply facade
|
|
720
|
+
as `reply.<verb>.js(ops)`) chains a `reactive:js` op stream onto ANY reply via
|
|
721
|
+
the immutable `stream()` plumbing, and `Streamable.broadcast_js_to(*streamables,
|
|
722
|
+
ops, exclude:, visible_to:, target:)` pushes the SAME ops to every subscriber of
|
|
723
|
+
a stream over `Turbo::StreamsChannel` (Action Cable AND pgbus — transport opts
|
|
724
|
+
pass through `broadcast_transport_opts` like every other broadcast). `ops` is a
|
|
725
|
+
`js` chain (or a raw `[[op, args]]` array), interpreted by the SAME frozen
|
|
726
|
+
`CLIENT_OPS` whitelist as `on_client` through a THIRD custom stream action
|
|
727
|
+
(`registerReactiveJs`, a sibling of `reactive:visit`/`reactive:token`): an
|
|
728
|
+
unknown op warns + is skipped (client-side default-deny). **The ordering contract
|
|
729
|
+
is correctness-critical**: the op stream is emitted AFTER all render streams, so
|
|
730
|
+
a `focus("[name=next]")` op sees the post-render/post-morph DOM (Turbo applies
|
|
731
|
+
streams in document order) — a system spec proves focus lands on a freshly
|
|
732
|
+
morphed field under Puma AND Falcon. **The broadcast builder REJECTS focus-class
|
|
733
|
+
ops** (`focus`/`focus_first` → `ArgumentError`): broadcasting focus would steal
|
|
734
|
+
it in every subscriber's tab, so focus is an actor-reply concern only. The ops
|
|
735
|
+
attribute is HTML-escaped exactly like `to_stream_token` (a raw interpolation
|
|
736
|
+
would be an injection vector), and `reactive:js` is NOT a self-render — it never
|
|
737
|
+
trips `#extractToken` or `carries_token_for?`, so the reply's token refresh is
|
|
738
|
+
untouched (pinned by a bun test and a request spec). Covered by Ruby specs
|
|
739
|
+
(immutable chaining; ops-last ordering; escaping; the broadcast focus rejection;
|
|
740
|
+
transport-opt pass-through with the pgbus-absent path unchanged), bun tests (the
|
|
741
|
+
handler applies ops via the shared interpreter; `target` root scoping; token
|
|
742
|
+
safety), a request spec (both streams in order with the token intact), and the
|
|
743
|
+
focus system spec. Refs #97.
|
|
744
|
+
|
|
745
|
+
- **`on(...)` event modifiers — `window:`, `once:`, `outside:`, `throttle:`
|
|
746
|
+
(#80).** Four trigger patterns that previously forced a hand-written Stimulus
|
|
747
|
+
controller are now declarable on `on(...)`. `outside: true` fires the action
|
|
748
|
+
only for events whose target is OUTSIDE the component's root — the
|
|
749
|
+
close-a-dropdown-on-outside-click pattern; an event inside the root is a
|
|
750
|
+
complete client-side no-op (bailing before `preventDefault` and before the
|
|
751
|
+
`reactive:before-dispatch` lifecycle event). It implies `window: true`, which
|
|
752
|
+
binds the trigger via Stimulus's native `@window` descriptor for page-level
|
|
753
|
+
events (`scroll`/`resize`). Window-bound triggers are NEVER
|
|
754
|
+
`preventDefault`-ed — a mounted dropdown must not kill native link clicks
|
|
755
|
+
elsewhere on the page — and skip the forced `type="button"`; the client
|
|
756
|
+
decides this from `data-reactive-window-param` (emitted as an explicit
|
|
757
|
+
`"true"` string — Phlex renders a boolean `true` attribute VALUELESS, which
|
|
758
|
+
Stimulus's param reader sees as `""`, falsy). `once: true` appends Stimulus's
|
|
759
|
+
`:once` action option (fire at most once, then unbind). `throttle:`
|
|
760
|
+
(milliseconds) rate-limits LEADING-EDGE — the first event fires immediately,
|
|
761
|
+
further events are dropped until the window elapses — the mirror of
|
|
762
|
+
`debounce:` (trailing-edge); passing both raises `ArgumentError`. Suppression
|
|
763
|
+
timers are keyed on action + target (window scroll events all share
|
|
764
|
+
`event.target === document`, so two window-bound triggers on one component
|
|
765
|
+
must not collide) and torn down on `disconnect()`. The bare `on(:x)` emission
|
|
766
|
+
is pinned byte-identical by spec — the four names become RESERVED `on(...)`
|
|
767
|
+
kwargs, no longer usable as free action params.
|
|
768
|
+
|
|
769
|
+
- **Single include + a default `#id` for record-backed components (#81).**
|
|
770
|
+
`include Phlex::Reactive::Component` now pulls in
|
|
771
|
+
`Phlex::Reactive::Streamable` automatically (ActiveSupport::Concern's
|
|
772
|
+
dependency mechanism includes Streamable into the base FIRST — exactly the
|
|
773
|
+
manual order the old two-include ceremony established), so one include is
|
|
774
|
+
enough; the legacy explicit double include remains a harmless no-op. And a
|
|
775
|
+
record-backed component (`reactive_record :todo`) gets `#id` for free:
|
|
776
|
+
`dom_id(record)` via the render-context-free `Streamable#dom_id` — the id
|
|
777
|
+
virtually every such component wrote by hand. An explicit `def id` always
|
|
778
|
+
wins (normal method lookup). Deliberately NO class-name default for
|
|
779
|
+
state-backed components — they're frequently multi-instance, so a class-name
|
|
780
|
+
id would silently collide; they (and bare Streamable classes) keep the loud
|
|
781
|
+
`NotImplementedError`, now with a message that says how to fix it. Caveat:
|
|
782
|
+
two different component classes rendering the SAME record on one page
|
|
783
|
+
collide on the default — give one a prefixed id
|
|
784
|
+
(`def id = dom_id(@todo, "rich")`). The component generator emits the new
|
|
785
|
+
short form. Same-machine `rake bench` before/after: allocations on the
|
|
786
|
+
render/token hot paths are byte-identical (192 obj per `to_stream_replace`,
|
|
787
|
+
100 per `render_component`, 47 per record-backed token, retained 0) and
|
|
788
|
+
throughput is unchanged within run-to-run noise — the default costs one
|
|
789
|
+
`respond_to?` + two memoized reads, and only for components that didn't
|
|
790
|
+
define `#id`.
|
|
791
|
+
|
|
792
|
+
- **`reactive_compute` reducers are told which field changed — one reducer can
|
|
793
|
+
express a multi-way/mutual rebalance (#75).** A compute reducer now receives a
|
|
794
|
+
second argument, `meta = { changed }`: the name of the declared input the
|
|
795
|
+
triggering `input` event edited, or `null` (a direct `recompute()` call, or a
|
|
796
|
+
target this root doesn't own / didn't declare as an input — nested reactive
|
|
797
|
+
roots stay excluded per #15). That's exactly what the mutual-rebalance shape
|
|
798
|
+
("three fields that must always sum to a total") needs — given the same value
|
|
799
|
+
snapshot, the reducer branches on WHICH field is the free/derived one:
|
|
800
|
+
|
|
801
|
+
```js
|
|
802
|
+
setComputeReducer("three_way_split", ({ field_a, field_b, field_c, total }, { changed }) => {
|
|
803
|
+
if (changed === "field_c") return { field_a: total - field_c - field_b }
|
|
804
|
+
return { field_c: total - field_a - field_b }
|
|
805
|
+
})
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
Fully backward compatible: a one-argument reducer just ignores `meta` — no
|
|
809
|
+
Ruby DSL change, no markup change. One contract to know: because an output
|
|
810
|
+
write dispatches a real change-guarded `input` event (#76), `recompute`
|
|
811
|
+
re-enters with `changed` = the OUTPUT field's name (when it's also a declared
|
|
812
|
+
input), so a branching reducer must be **convergent** — the re-entrant pass
|
|
813
|
+
must compute the values already in the DOM so the change guard settles the
|
|
814
|
+
chain (the example above does: deriving `field_a` back from the just-written
|
|
815
|
+
`field_c` reproduces its current value; no write, no event, settled in one
|
|
816
|
+
bounce). Documented in `compute.js`'s header and the `reactive_compute` docs;
|
|
817
|
+
covered by bun unit tests including the issue's three-way rebalance verbatim
|
|
818
|
+
with a bounded reducer-call count.
|
|
819
|
+
|
|
820
|
+
- **`Phlex::Reactive.verbose_errors` — diagnostic endpoint error bodies +
|
|
821
|
+
dropped-param logging (#82).** An endpoint failure used to be a bare
|
|
822
|
+
`head 400/403/404` and a silently-dropped param left no trace — debugging
|
|
823
|
+
"nothing happens" meant reading the gem source. Now every failure is
|
|
824
|
+
warn-logged as `[phlex-reactive] …` in EVERY environment, and with
|
|
825
|
+
`verbose_errors` on (default `Rails.env.local?` — development AND test; off
|
|
826
|
+
in production) the response also carries a plain-text diagnostic body the
|
|
827
|
+
client already prints via `console.error`: a tampered/stale token (400,
|
|
828
|
+
distinguishing signature-invalid from a token class that no longer resolves
|
|
829
|
+
from a class that isn't reactive — `InvalidToken` now carries a `diagnostic`),
|
|
830
|
+
an undeclared action (403, listing the declared actions), a registered
|
|
831
|
+
authorization error (403, naming the error class and the action), and a
|
|
832
|
+
missing record (404, naming the GlobalID). Param coercion additionally logs
|
|
833
|
+
every dropped key with its full bracketed path and reason
|
|
834
|
+
(`undeclared` / `uncoercible`), hinting when a flat name looks like the
|
|
835
|
+
bracketed twin of a declared nested key (the #16/#21 confusion). Statuses
|
|
836
|
+
never change with the flag, the client needs no changes, and the production
|
|
837
|
+
coercion path does zero extra work (nil collector, early-return guards) —
|
|
838
|
+
`rake bench:one[coerce_params]` before/after is unchanged within noise
|
|
839
|
+
|
|
840
|
+
- **Client lifecycle CustomEvents — `reactive:before-dispatch` /
|
|
841
|
+
`reactive:applied` / `reactive:error` with `retry()` (#79).** The generic
|
|
842
|
+
controller now dispatches three bubbling, composed events around every action
|
|
843
|
+
round trip, so an app can toast an error, veto a dispatch, instrument latency,
|
|
844
|
+
or build retry UI without forking the one controller.
|
|
845
|
+
`reactive:before-dispatch` is cancelable and fires once per user gesture —
|
|
846
|
+
post-`preventDefault`, post-`confirm:`, PRE-debounce — with
|
|
847
|
+
`{ action, params, element }`; `event.preventDefault()` skips the round trip
|
|
848
|
+
entirely (nothing is scheduled, debounced or not). `reactive:applied` fires
|
|
849
|
+
with `{ action, params, html }` after the fresh token was captured and the
|
|
850
|
+
streams were handed to `Turbo.renderStreamMessage` (Turbo applies them
|
|
851
|
+
asynchronously — listen to Turbo's own events for post-morph timing).
|
|
852
|
+
`reactive:error` fires in every failure branch with
|
|
853
|
+
`{ action, params, kind, status?, body?, retry? }` where `kind` is one of
|
|
854
|
+
`redirected | http | content-type | network` (all retriable) or `apply` —
|
|
855
|
+
the fetch itself succeeded and the server already completed the mutation,
|
|
856
|
+
but something AFTER it threw INSIDE THE CONTROLLER (a malformed response, a
|
|
857
|
+
Turbo render error — NOT a throwing `reactive:applied` listener, whose
|
|
858
|
+
exception `dispatchEvent` never propagates back per the DOM spec, so it
|
|
859
|
+
can't reach this catch); `apply` carries NO `retry` at all, since retrying
|
|
860
|
+
would re-POST an action that already succeeded. `retry()`
|
|
861
|
+
(when present) re-enters the request queue — re-reading the freshest signed
|
|
862
|
+
token and re-collecting the fields at send time, refiring no second
|
|
863
|
+
before-dispatch — and no-ops with a `console.warn` once the component left
|
|
864
|
+
the DOM. Events go out via raw
|
|
865
|
+
`dispatchEvent` (Stimulus's `this.dispatch` helper is shadowed by the
|
|
866
|
+
controller's own `dispatch` action method) on the root element, falling back
|
|
867
|
+
to `document` when a plain replace detached it; the existing `console.error`
|
|
868
|
+
logging is unchanged. Composes with plain Stimulus listening —
|
|
869
|
+
`data-action="reactive:error->toast#show"` on an ancestor. Covered by unit
|
|
870
|
+
(JS) and real-browser system specs; README "Failure UX & lifecycle events"
|
|
871
|
+
and the security docs page document the contract.
|
|
872
|
+
|
|
11
873
|
- **Combobox keyboard navigation — `on(:search, …, listnav: "[role=option]")` (#72).**
|
|
12
874
|
A search/combobox trigger can now declare client-side list navigation: Arrow
|
|
13
875
|
Up/Down move a highlight among the option elements IN-BROWSER (no round trip),
|
|
@@ -58,9 +920,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
58
920
|
```
|
|
59
921
|
|
|
60
922
|
The generic controller runs the named reducer on `input`, writes only the
|
|
61
|
-
declared outputs (leaving the edited field + caret alone), and
|
|
62
|
-
|
|
63
|
-
|
|
923
|
+
declared outputs (leaving the edited field + caret alone), and — for each
|
|
924
|
+
output whose value actually changed — dispatches a bubbling `input` event on
|
|
925
|
+
the field so a chained summary repaints, matching the server's `set_value` +
|
|
926
|
+
`dispatch("input")` contract (see #76). A missing/unregistered reducer is a
|
|
64
927
|
no-op (a page never breaks because a binding wasn't wired up). When the same
|
|
65
928
|
component ALSO carries `on(...)` (a persisted record, or a draft you sync), that
|
|
66
929
|
debounced POST still fires and the server reply reconciles — so `reactive_compute`
|
|
@@ -127,6 +990,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
127
990
|
|
|
128
991
|
### Documentation
|
|
129
992
|
|
|
993
|
+
- **Corrected the broadcast render-cost mental model — "N subscribers = N
|
|
994
|
+
renders" was wrong (#78).** A `broadcast_*_to` call renders the component
|
|
995
|
+
ONCE and passes the finished `html:` to `Turbo::StreamsChannel`, so every
|
|
996
|
+
subscriber of that stream shares one payload (the per-subscriber cost is
|
|
997
|
+
transport-side). The render cost is per CALL — broadcasting one change to K
|
|
998
|
+
different stream keys is K builds + K renders + K token signings of
|
|
999
|
+
byte-identical HTML — and per-viewer rendering (`visible_to:`-style content)
|
|
1000
|
+
is the irreducible render-per-viewer case. Fixed in the performance docs
|
|
1001
|
+
page, the render micro-bench comment, and CLAUDE.md; also repointed stale
|
|
1002
|
+
`docs/performance.md` references to the docs app's performance page
|
|
1003
|
+
(`docs/app/views/docs/pages/performance.rb`). No behavior changed.
|
|
1004
|
+
|
|
130
1005
|
- **The auto-collected-params contract, spelled out (#64, #65, #66, #67).** Four
|
|
131
1006
|
gaps surfaced from building one model-scoped form (numeric fields that rebalance
|
|
132
1007
|
live). No behavior changed — the README now documents what the code already
|
|
@@ -206,6 +1081,48 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
206
1081
|
|
|
207
1082
|
### Fixed
|
|
208
1083
|
|
|
1084
|
+
- **`reactive_compute` output writes never fired real `input` events — chained
|
|
1085
|
+
repaints were dead in production (#76).** The controller's `recompute()` wrote
|
|
1086
|
+
each output with a bare `field.value = …` under a comment claiming the write
|
|
1087
|
+
fires the field's `input` listeners. Only the bun-test fake's custom `value`
|
|
1088
|
+
setter did that — real browsers never fire `input` on a programmatic `.value`
|
|
1089
|
+
write — so anything listening on a computed field (a chained summary repaint,
|
|
1090
|
+
a second compute) silently never ran outside the test suite. The controller now
|
|
1091
|
+
dispatches a real bubbling `new Event("input")` after each output write, and
|
|
1092
|
+
the write is **change-guarded**: a field is written (and the event dispatched)
|
|
1093
|
+
ONLY when the reducer's value differs from the field's current value
|
|
1094
|
+
(String-compared). The guard is load-bearing, not an optimization — the shipped
|
|
1095
|
+
`payment_split` example declares overlapping inputs/outputs, so an
|
|
1096
|
+
unconditional dispatch would re-enter `input->reactive#recompute` forever;
|
|
1097
|
+
with the guard, chains settle deterministically (the re-entrant pass writes
|
|
1098
|
+
nothing and stops). The bun-test fake now matches real DOM (no auto-fire on
|
|
1099
|
+
`.value` assignment; listeners run only through the controller's explicit
|
|
1100
|
+
`dispatchEvent`), with unit coverage for the unchanged-write no-op, the
|
|
1101
|
+
single bubbling dispatch, loop termination on the payment_split shape, and a
|
|
1102
|
+
chained listener firing via the dispatched event — plus a real-browser system
|
|
1103
|
+
assertion that a derived field repaints after typing. The misleading comments
|
|
1104
|
+
in `reactive_controller.js` and `compute.js` now describe the real contract.
|
|
1105
|
+
|
|
1106
|
+
- **`reply.flash` discarded its level — `:error` and `:notice` emitted
|
|
1107
|
+
byte-identical streams (#77).** `Response.flash_stream` declared the level as
|
|
1108
|
+
`_level` and never used it, so an app could not style errors red without
|
|
1109
|
+
abandoning the flash helper — while the public API and every README example
|
|
1110
|
+
pass a level. String flash content is now wrapped in
|
|
1111
|
+
`<div class="reactive-flash reactive-flash--{level}"
|
|
1112
|
+
data-reactive-flash-level="{level}">…</div>`, so the level reaches the wire as
|
|
1113
|
+
a style hook (class) and a script/test hook (data attribute); the level is
|
|
1114
|
+
HTML-escaped before landing in either attribute. **This intentionally changes
|
|
1115
|
+
the wire output for string flashes** (previously the bare string was appended
|
|
1116
|
+
with no wrapper) — restyle against `.reactive-flash--{level}` if you targeted
|
|
1117
|
+
the raw text node. The string itself keeps the exact pre-existing injection
|
|
1118
|
+
contract, now applied inside the wrapper: a plain String is HTML-escaped, an
|
|
1119
|
+
`html_safe` String passes verbatim. Phlex **component** content still renders
|
|
1120
|
+
VERBATIM — byte-identical to before, no wrapper (the caller owns the markup;
|
|
1121
|
+
a spec pins it). New config `Phlex::Reactive.flash_component = MyFlash`
|
|
1122
|
+
(default nil) renders string flashes through your own component instead of
|
|
1123
|
+
the default wrapper — instantiated `MyFlash.new(level:, content:)` and
|
|
1124
|
+
rendered through the existing render path; component content bypasses it.
|
|
1125
|
+
|
|
209
1126
|
- **`reactive_controller.js` used a relative `./confirm.js` import that 404'd under
|
|
210
1127
|
importmap-rails + Propshaft — taking down every Stimulus controller on the page (#57).**
|
|
211
1128
|
The #55 confirm resolver added `import { confirmResolver } from "./confirm.js"` to the
|
|
@@ -687,7 +1604,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
687
1604
|
scaffolds a reactive component (and an RSpec spec when the app uses RSpec),
|
|
688
1605
|
state-backed by default or record-backed with `--record`.
|
|
689
1606
|
|
|
690
|
-
[Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.
|
|
1607
|
+
[Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.9.0...HEAD
|
|
1608
|
+
[0.9.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.6...v0.9.0
|
|
1609
|
+
[0.2.6]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.5...v0.2.6
|
|
1610
|
+
[0.2.5]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.4...v0.2.5
|
|
691
1611
|
[0.2.4]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.3...v0.2.4
|
|
692
1612
|
[0.2.3]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.2...v0.2.3
|
|
693
1613
|
[0.2.2]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.1...v0.2.2
|