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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1097 -630
  3. data/README.md +638 -16
  4. data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
  5. data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
  6. data/app/javascript/phlex/reactive/core.js +181 -0
  7. data/app/javascript/phlex/reactive/core.min.js +4 -0
  8. data/app/javascript/phlex/reactive/core.min.js.map +11 -0
  9. data/app/javascript/phlex/reactive/early.js +175 -0
  10. data/app/javascript/phlex/reactive/early.min.js +4 -0
  11. data/app/javascript/phlex/reactive/early.min.js.map +10 -0
  12. data/app/javascript/phlex/reactive/features/bindings.js +1511 -0
  13. data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
  14. data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
  15. data/app/javascript/phlex/reactive/features/compute.js +541 -0
  16. data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
  17. data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
  18. data/app/javascript/phlex/reactive/features/defer.js +726 -0
  19. data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
  20. data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
  21. data/app/javascript/phlex/reactive/features/devtools.js +242 -0
  22. data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
  23. data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
  24. data/app/javascript/phlex/reactive/features/effects.js +362 -0
  25. data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
  26. data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
  27. data/app/javascript/phlex/reactive/features/form.js +216 -0
  28. data/app/javascript/phlex/reactive/features/form.min.js +4 -0
  29. data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
  30. data/app/javascript/phlex/reactive/features/hints.js +193 -0
  31. data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
  32. data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
  33. data/app/javascript/phlex/reactive/features/persist.js +663 -0
  34. data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
  35. data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
  36. data/app/javascript/phlex/reactive/inspect.js +21 -9
  37. data/app/javascript/phlex/reactive/inspect.min.js +2 -2
  38. data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
  39. data/app/javascript/phlex/reactive/reactive_controller.js +85 -5580
  40. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  41. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
  42. data/app/javascript/phlex/reactive/runtime.js +3690 -0
  43. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
  44. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
  45. data/lib/phlex/reactive/component/dsl.rb +74 -2
  46. data/lib/phlex/reactive/component/helpers.rb +104 -28
  47. data/lib/phlex/reactive/component/lazy.rb +138 -2
  48. data/lib/phlex/reactive/component/registry.rb +2 -0
  49. data/lib/phlex/reactive/component/selection.rb +71 -0
  50. data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
  51. data/lib/phlex/reactive/doctor.rb +166 -8
  52. data/lib/phlex/reactive/dormant.rb +66 -0
  53. data/lib/phlex/reactive/engine.rb +64 -3
  54. data/lib/phlex/reactive/fragment.rb +233 -0
  55. data/lib/phlex/reactive/inspector/report.rb +24 -3
  56. data/lib/phlex/reactive/inspector.rb +23 -2
  57. data/lib/phlex/reactive/js.rb +61 -12
  58. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
  59. data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
  60. data/lib/phlex/reactive/param_schema.rb +68 -54
  61. data/lib/phlex/reactive/pending/markup.rb +128 -0
  62. data/lib/phlex/reactive/pending.rb +25 -21
  63. data/lib/phlex/reactive/show_conditions.rb +84 -56
  64. data/lib/phlex/reactive/streamable.rb +20 -2
  65. data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
  66. data/lib/phlex/reactive/test_helpers/system.rb +62 -0
  67. data/lib/phlex/reactive/version.rb +1 -1
  68. data/lib/phlex/reactive.rb +84 -0
  69. data/lib/tasks/phlex_reactive.rake +6 -2
  70. metadata +39 -1
data/README.md CHANGED
@@ -109,7 +109,129 @@ import ReactiveController from "phlex/reactive/reactive_controller"
109
109
  application.register("reactive", ReactiveController)
110
110
  ```
111
111
 
112
- Register eagerly (not lazily) so a click immediately after load is never missed.
112
+ Eager registration is the simple, safe default. To load the controller lazily
113
+ instead, import `phlex/reactive/early` eagerly (the engine pins it with
114
+ `preload: true`) so the triggers below are not lost while it loads:
115
+
116
+ ```js
117
+ // app/javascript/application.js — before any controller loads
118
+ import "phlex/reactive/early"
119
+ ```
120
+
121
+ It gzips to under 1.3 KB and has no Stimulus import. A trigger (`on(...)` or
122
+ `on_client(...)`) that fires before the controller connects — a click while a
123
+ lazily loaded controller is still downloading, a custom event dispatched as soon
124
+ as the page is interactive — is queued, its native default is stopped exactly
125
+ where the controller would stop it, and the controller replays it when it
126
+ connects. Each root announces that moment with a bubbling `reactive:connect`
127
+ event and a `data-reactive-connected` attribute. Without the import, register
128
+ the controller eagerly: an event that fires before connect is otherwise lost.
129
+
130
+ A queued trigger older than 10 s when its controller connects is dropped (a
131
+ warning under `data-reactive-verbose`), as is one whose element left the page.
132
+ Change the window with `Phlex::Reactive.early_event_ttl_ms` and a meta tag:
133
+
134
+ ```erb
135
+ <meta name="phlex-reactive-early-ttl" content="<%= Phlex::Reactive.early_event_ttl_ms %>">
136
+ ```
137
+
138
+ Good to know:
139
+
140
+ - Every firing before connect is replayed (up to 50), so give a high-frequency
141
+ trigger (`input`, `mouseover`) a `debounce:` or `throttle:` as usual.
142
+ - If you already wait for the controller yourself and then dispatch (a
143
+ `getControllerForElementAndIdentifier` polling loop), remove that loop when
144
+ you add the import, or the trigger fires twice.
145
+ - `data-reactive-connected` is set at connect; an in-place morph strips it
146
+ again. Listen for `reactive:connect` rather than reading the attribute later.
147
+ - Until the controller connects, a captured link or form trigger has its
148
+ native behavior stopped and nothing else happens. If the controller never
149
+ loads, or the entry is dropped (older than the TTL, its element gone, more
150
+ than 50 queued), that click or submit does nothing.
151
+ - `window:` triggers — how a **hotkey** is usually bound
152
+ (`on(:toggle, event: "keydown.k", window: true)` or
153
+ `on_client("keydown.k", …, window: true)`) — are captured too: a hotkey
154
+ pressed anywhere on the page before the controller connects is replayed,
155
+ and it wakes a dormant root. Two differences from element-bound triggers:
156
+ the keypress is never prevented (a browser shortcut on the same key keeps
157
+ working, as it does once connected), and it is replayed only if the
158
+ controller connects within 1.5 s of the keypress (or the TTL above, if
159
+ shorter) — later, the user has moved on. Two roots sharing a hotkey both
160
+ hear it, as they do once connected: one press wakes both and each handles
161
+ it. A `once: true` hotkey replayed before connect stays spent for the
162
+ root until it disconnects — keep its trigger element stable across replies
163
+ (a reply that morphs the root but swaps that element leaves the hotkey
164
+ dead until the next disconnect), or bind the hotkey without `once:`.
165
+ - Not captured, so still lost before connect: `outside:` triggers (an outside
166
+ click before connect has nothing to close, and recording it would wake a
167
+ dormant dropdown on every click); key filters beyond Stimulus's default key
168
+ names; and every other controller action — only `on(...)` (`reactive#dispatch`) and
169
+ `on_client(...)` (`reactive#runOps`) are replayed, not the built-in
170
+ `nestedAdd`/`nestedRemove`, `tagsAdd`/`tagsPick`, `listnav*` or `recompute`
171
+ bindings.
172
+
173
+ **Dormant roots: mount on first use.** Loading the controller lazily only helps
174
+ on pages with no reactive root: a root renders `data-controller="reactive"`, and
175
+ that is what makes Stimulus load and connect it. A root that only matters after
176
+ a gesture (a closed dialog, a menu that loads on open) can stay dormant instead:
177
+
178
+ ```ruby
179
+ class ItemsPanel < ApplicationComponent
180
+ include Phlex::Reactive::Component
181
+
182
+ reactive_dormant # every root of this component (inherited)
183
+ action :load
184
+
185
+ def view_template
186
+ div(**mix(reactive_root, on(:load, event: "panel:opened", once: true))) { … }
187
+ end
188
+ end
189
+
190
+ div(**reactive_root(dormant: true)) { … } # or one render at a time
191
+ ```
192
+
193
+ A dormant root renders `data-reactive-dormant="reactive"` in place of the
194
+ controller attribute, so nothing mounts and a lazily loaded controller is not
195
+ fetched. The first `on(...)`/`on_client(...)` trigger that reaches it — a
196
+ `window:` hotkey pressed anywhere on the page included, an `outside:` one
197
+ not — wakes it:
198
+ `phlex/reactive/early` moves the identifier into `data-controller`, Stimulus
199
+ loads and connects the controller, and the trigger is replayed once.
200
+
201
+ - **It needs `import "phlex/reactive/early"`.** Without that module nothing
202
+ wakes the root: a link or form trigger just does its native thing (the link
203
+ navigates, the form posts), and a dormant root nested inside an awake one
204
+ hands its triggers to the OUTER component (they run the outer's action of
205
+ that name with the outer's token, or get a 403).
206
+ `bin/rails phlex_reactive:doctor` lists the dormant components and says
207
+ where it found the import (an app JS entry, or an inline module script in a
208
+ view or component). With more than one import map, every map needs the
209
+ `phlex/reactive/early` pin: the doctor names each one without it, and
210
+ `Phlex::Reactive.importmaps = -> { { "landing" => landing_map } }` adds maps
211
+ that are not `Rails.application.importmap`.
212
+ - The actor's own reply, a `reactive_lazy(on:)` materialize and the defer
213
+ endpoint render the root awake, since that page's controller is loaded by
214
+ then. Everything else renders it dormant: the page, a broadcast (`broadcast_to`,
215
+ also inside an action), a page refresh, a deferred render pushed over a
216
+ stream. If one of those lands on an awake root, the next trigger wakes it
217
+ again. A `to_stream_replace` you build inside an action and send to other
218
+ pages yourself is rendered awake, so broadcast with `broadcast_to` instead.
219
+ - Use it for a root whose only behavior is its triggers. What the controller
220
+ does at connect (`reactive_persist` restore, `reactive_compute` seeding,
221
+ show/filter sync, dirty tracking) waits for the wake, and the browser
222
+ inspector (`inspect.js`) does not list a dormant root.
223
+ - With `reactive_lazy`: an event shell (`reactive_lazy on: "panel:opened"`) can
224
+ be dormant, and that event wakes and loads it in one go. `on: :visible`
225
+ cannot (its shell has no trigger to wake on; declaring both raises), and a
226
+ plain `reactive_lazy` shell always mounts, because it fetches on connect. On
227
+ a lazy component use the class-level `reactive_dormant`: the shell is rendered
228
+ by the framework and never sees a per-render `reactive_root(dormant: true)`.
229
+ - With a controller of your own on the same root, list it first:
230
+ `mix({ data: { controller: "dropdown" } }, reactive_root)`. Waking appends
231
+ `reactive`, which then matches what an awake reply renders; in the other
232
+ order the first morph reply reconnects both controllers once.
233
+ - A dormant root nested inside an awake one belongs to the outer root until it
234
+ wakes: its fields are collected with the outer root's actions.
113
235
  </details>
114
236
 
115
237
  ### Scaffold a component
@@ -135,13 +257,178 @@ import ReactiveController from "phlex/reactive/reactive_controller"
135
257
  application.register("reactive", ReactiveController)
136
258
  ```
137
259
 
138
- The gem ships a prebuilt, minified `reactive_controller.min.js` (~22 KB vs the
139
- ~106 KB commented source) with a linked sourcemap, and auto-pins it for importmap
140
- apps — so browsers load the small file while devtools still shows the real code.
141
- Point your bundler at the gem path or copy the `.min.js` (+ its `.map`) in. See
260
+ The gem ships a prebuilt, minified `reactive_controller.min.js` (the whole
261
+ client in one file, about 25 KB gzipped) with a linked sourcemap, and
262
+ auto-pins it for importmap apps — so browsers load the small file while
263
+ devtools still shows the real code. It imports three small modules by their
264
+ bare names — `phlex/reactive/confirm`, `phlex/reactive/confirm_predicate` and
265
+ `phlex/reactive/compute`, the seams you can override — so point your bundler
266
+ at the gem's `app/javascript` directory (one prefix alias for
267
+ `phlex/reactive`), or alias the controller and those three names. See
142
268
  [docs/installation.md](https://phlex-reactive.zoolutions.llc/docs/installation).
143
269
  </details>
144
270
 
271
+ ### The split client (opt-in): a smaller core, features on demand
272
+
273
+ `phlex/reactive/reactive_controller` is **one file with everything in it**.
274
+ That is the default, and nothing about it changes: one import, the whole
275
+ client, nothing fetched later.
276
+
277
+ The same client also ships split in two, for an app that wants the smallest
278
+ possible script on its light pages:
279
+
280
+ ```js
281
+ // instead of "phlex/reactive/reactive_controller" — never both
282
+ import ReactiveController from "phlex/reactive/core"
283
+ application.register("reactive", ReactiveController)
284
+ ```
285
+
286
+ `phlex/reactive/core` is the controller **without** its feature modules. It
287
+ imports one the first time a root on the page needs it:
288
+
289
+ | Module | Gzipped | Default client | Split client |
290
+ |---|---:|---|---|
291
+ | `phlex/reactive/reactive_controller` | 26.1 KB | loaded with your controllers | not loaded |
292
+ | `phlex/reactive/core` | 12.4 KB | not loaded (it is inside the file above) | loaded with your controllers |
293
+ | `phlex/reactive/features/persist` (`reactive_persist` drafts) | 3.2 KB | inside the one file | fetched when a root declares `reactive_persist`, or a `persist_state` / `persist_clear` op runs |
294
+ | `phlex/reactive/features/defer` (`reply.defer`, `reactive_lazy`) | 2.8 KB | inside the one file | fetched when a root is a `reactive_lazy` shell, a morph turns one into a shell, or a `reply.defer` arrives |
295
+ | `phlex/reactive/features/form` (dirty tracking, `warn_unsaved`, the paste-trigger gate) | 1.0 KB | inside the one file | fetched when a root tracks dirty fields or holds a `paste_into` trigger |
296
+ | `phlex/reactive/features/effects` (`reactive_effects`, `dismiss_after`) | 1.7 KB | inside the one file | fetched when a root declares an effect, or when the first stream that needs it arrives: one with an effect, or with a dismissing flash |
297
+ | `phlex/reactive/features/bindings` (`reactive_show`, show targets, `reactive_on_complete`, `reactive_filter`, `reactive_tags`, nested rows, the conditional `confirm:`) | 5.4 KB | inside the one file | fetched when a root declares one of them, or holds a trigger with a conditional confirm |
298
+ | `phlex/reactive/features/compute` (`reactive_compute`, `reactive_text`) | 2.1 KB | inside the one file | fetched when a root carries a compute binding |
299
+ | `phlex/reactive/features/hints` (`optimistic:`, `busy:`) | 1.0 KB | inside the one file | fetched when a root holds a trigger that declares a hint |
300
+ | `phlex/reactive/features/devtools` (the latency simulator, the zero-target warnings, the debug trace) | 1.6 KB | inside the one file | fetched with the core on a page that carries `<meta name="phlex-reactive-env" content="development">` or while a delay is stored for the tab; at connect for a root in debug mode; otherwise the first time a verbose root has something to warn about. In production with none of these, never |
301
+ | `phlex/reactive/early` | 1.2 KB | on every page, if you import it | the same |
302
+
303
+ That is the whole split: everything that can leave the core has. What
304
+ remains in it — the request pipeline, the signed token, field collection,
305
+ client ops and `on_client` bindings, early-trigger replay, list navigation —
306
+ is what every reactive page uses.
307
+
308
+ **What you trade.** With the default client a feature is simply there when a
309
+ root connects. With the split client the **first** root of a page load that
310
+ needs a feature waits for one small request (cached afterwards; on a Turbo
311
+ visit the module is already loaded and nothing waits). While it waits:
312
+
313
+ - a `reactive_persist` root shows what the server rendered. What the user
314
+ changes is kept and drafted when the module arrives (also when they leave
315
+ the page before it does); an action they fire waits for the
316
+ restore and posts the restored values — unless the module is slower than
317
+ the feature timeout (10 s), fails to load, or the root leaves the page
318
+ first, in which case it goes out with the values on the page;
319
+ - a `reactive_lazy` shell shows its placeholder a moment longer; an `on:`
320
+ event that fires in that window loads the shell once the module is there;
321
+ - a stream that brings the page's first effect or dismissing flash — a
322
+ broadcast, an action's reply — is rendered when the effects module has
323
+ arrived, so that very stream is animated and its flash dismissed on time
324
+ (`dismiss_after:` counts from the render). Every stream that arrives while
325
+ it waits renders after it, in order. The wait is capped at one second
326
+ (under an import that never settles, each later stream that needs the
327
+ feature pays its own one-second hold — bounded, never wedged — and a plain
328
+ stream behind it waits with it, for order);
329
+ after that the streams render without the effect, and a flash they brought
330
+ is dismissed counting from the module's arrival instead. A root that
331
+ declares an effect loads the module when it connects, so this only happens
332
+ on a page where no root does;
333
+ - a dirty-tracked form counts an edit made in the window when the module
334
+ arrives. `warn_unsaved:` does not prompt for a navigation in the window
335
+ itself: preload the module on a page where that matters (below);
336
+ - a form's bindings — `reactive_show`, `reactive_filter`, the tag chips, the
337
+ nested-row JSON field — show what the server rendered until the module
338
+ arrives, then seed from the fields as they are: a choice made in the window
339
+ is kept and reflected, a tag picked in the window is added, a conditional
340
+ `confirm:` asks once the module can evaluate it (and proceeds without a
341
+ dialog if the module cannot load or is slower than the feature timeout —
342
+ the endpoint's authorization is the real gate). Because the JSON-mode rows
343
+ write a field a request collects, a root with any of these holds its
344
+ requests until the module is here (bounded as above);
345
+ - a `reactive_compute` root shows the server's values for its outputs until
346
+ the module arrives (about 20–25 ms with the module served from the same
347
+ host, measured on the split client in the browser suite; one request's
348
+ worth on a real network), then seeds; an edit made in the window is
349
+ recomputed from the value the user typed, never overwritten. Such a root
350
+ holds its requests until the module is here, for the same reason;
351
+ - a trigger with an `optimistic:` or `busy:` hint: its request waits for the
352
+ hints module (at most the feature timeout); the hint applies if the module
353
+ arrives in time, and after a timeout or a failed load the request goes out
354
+ without it — once either way. The busy markers (`aria-busy`,
355
+ `data-reactive-busy`, `busy_on`) are on from the click, as always; a
356
+ failure after a late apply still reverts the optimistic hint. A root whose
357
+ triggers declare a hint loads the module when it connects, so this is the
358
+ first click of a page's first root at most. In that window a
359
+ `busy: { disable: true }` hint cannot stop a second click: three rapid
360
+ clicks are three ordered requests (the default bundle's synchronous
361
+ disable blocks the second and third);
362
+ - the latency simulator delays a request from the first one on (the request
363
+ waits for the module); a zero-target warning on a verbose root, or a debug
364
+ trace, is logged once the module is here. `enableLatencySim` /
365
+ `disableLatencySim` are exported by `phlex/reactive/features/devtools`
366
+ here, not by the controller.
367
+
368
+ None of that exists with the default client.
369
+
370
+ **Taking the small core without the network wait.** Preload the features
371
+ you know a page uses:
372
+
373
+ ```ruby
374
+ # config/importmap.rb — after the engine's own pins
375
+ pin "phlex/reactive/core", to: "phlex/reactive/core.min.js", preload: true
376
+ pin "phlex/reactive/features/persist", to: "phlex/reactive/features/persist.min.js", preload: true
377
+ pin "phlex/reactive/features/form", to: "phlex/reactive/features/form.min.js", preload: true # the unsaved guard a task after connect
378
+ pin "phlex/reactive/features/bindings", to: "phlex/reactive/features/bindings.min.js", preload: true # show/filter/tags a task after connect
379
+ pin "phlex/reactive/features/compute", to: "phlex/reactive/features/compute.min.js", preload: true # computed fields a task after connect
380
+ ```
381
+
382
+ (or a `<link rel="modulepreload">` on the pages that need it). The module is
383
+ then already loaded, so there is no network wait — but a preloaded module
384
+ still arrives through `import()`, which resolves in a later task: the
385
+ feature connects in the task **after** `connect()`, not inside it (measured:
386
+ with `modulepreload` the compute seed lands 29–51 ms after `reactive:connect`
387
+ on localhost), `featuresReady` is pending for that moment, and the first
388
+ root's request gate applies to it. The default bundle is the only way to get
389
+ connect-time seeds. A root that is
390
+ [dormant](#installation) fetches nothing at all — not the core, not a feature —
391
+ until its first trigger; the trigger is then replayed after the core has
392
+ loaded, and waits for a feature like any other.
393
+
394
+ **Bundlers.** The core reaches a feature with a literal
395
+ `import("phlex/reactive/features/persist")`. Give your bundler **one prefix
396
+ alias** for the gem's JavaScript directory, which covers the core, the seams
397
+ and every feature, now and later:
398
+
399
+ ```js
400
+ // esbuild (build script)
401
+ import * as esbuild from "esbuild"
402
+ import { execSync } from "node:child_process"
403
+ const gemJs = `${execSync("bundle show phlex-reactive").toString().trim()}/app/javascript`
404
+
405
+ await esbuild.build({
406
+ // …
407
+ splitting: true, format: "esm", // each feature becomes its own chunk
408
+ plugins: [{
409
+ name: "phlex-reactive",
410
+ setup(build) {
411
+ build.onResolve({ filter: /^phlex\/reactive\// }, ({ path }) => ({ path: `${gemJs}/${path}.min.js` }))
412
+ },
413
+ }],
414
+ })
415
+ ```
416
+
417
+ (Vite/webpack: the same rule as a prefix `resolve.alias` for `phlex/reactive`.)
418
+ Without code splitting the features are bundled into your entry, which is the
419
+ default client with extra steps. With exact per-module aliases the build fails
420
+ on the feature it cannot find (esbuild: `Could not resolve
421
+ "phlex/reactive/features/persist"`). If you pin copied
422
+ files by hand in an import map, pin `phlex/reactive/core` and each
423
+ `phlex/reactive/features/*` too; a feature that cannot be imported logs one
424
+ console error naming it, marks the root `data-reactive-error="feature"`, emits
425
+ `reactive:error` (`kind: "feature"`) and does nothing. The specifiers are
426
+ bare, not relative, because Propshaft serves each file under its own digest
427
+ and only the import map can resolve one to another (issue #57).
428
+
429
+ **One or the other.** Each entry carries its own copy of the client's state.
430
+ Loading both logs `the client was loaded twice` to the console.
431
+
145
432
  **Requirements:** Rails 7.1+, Phlex 2 (`phlex-rails`), Turbo 8+ (for morphing),
146
433
  and a Phlex `ApplicationComponent` base class. pgbus is optional but recommended
147
434
  for broadcasting.
@@ -199,10 +486,15 @@ guide](https://phlex-reactive.zoolutions.llc/docs/tooling) for the full workflow
199
486
  ```bash
200
487
  bin/rails phlex_reactive:doctor # validate the whole install (✓/✗/? + a fix each)
201
488
  bin/rails phlex_reactive:actions # every component × action: params, file:line, auth
489
+ bin/rails phlex_reactive:actions UNVERIFIED=1 # only actions with no detected authorization AND no skip
202
490
  bin/rails "phlex_reactive:find[counter]" # fuzzy-find one; prints each action's method source
203
491
  bin/rails phlex_reactive:mcp # a read-only MCP server (needs `gem "mcp"`)
204
492
  ```
205
493
 
494
+ The `auth` column reads `authorized*` (an authorization call was detected — a
495
+ heuristic), `skipped` / `skipped (class)` (declared `skip_verify_authorized`,
496
+ per action or component-wide), or `unverified` (neither — review these).
497
+
206
498
  In the browser console, map every reactive root + trigger on the page back to its
207
499
  server `Component#action` names (a standalone module — zero cost until imported):
208
500
 
@@ -372,6 +664,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
372
664
  | `skip_verify_authorized [ :a, :b ]` | Opt a component (bare) or specific actions out of the default-ON `verify_authorized` guard — for a genuinely public component (a counter, a client-only filter). |
373
665
  | `reactive_root(**overrides)` | Spread onto the root element: emits the component `id` **and** `reactive_attrs` together, so the controller root always carries `#id`. Preferred over `id:` + `reactive_attrs`. `**overrides` (`class:`/`data:`) deep-merge. `compute: :name` binds a client-side compute **at the root** — descriptors plus the `input->reactive#recompute` delegation, so no field needs its own wiring; `nil` collapses to no binding. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
374
666
  | `reactive_attrs` | Marks an element reactive + carries the signed token (no `id`). Spread alongside `id:` on the **same** element: `div(id:, **reactive_attrs)`. Prefer `reactive_root`, which can't split them. |
667
+ | `reactive_dormant` / `reactive_root(dormant: true)` | Mount the controller on first use: the root renders `data-reactive-dormant="reactive"` instead of `data-controller="reactive"` until one of its triggers fires. Class-level and inherited (`reactive_dormant false` turns it off again), or per render (`reactive_attrs(dormant: true)` too). Needs `import "phlex/reactive/early"`. See the dormant roots note under [Installation](#installation). |
375
668
  | `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
376
669
  | `on(:action, event: "input", debounce: 300)` | Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
377
670
  | `on(:action, event: "keydown.enter")` | Fire only on a specific key — Enter-to-submit / Escape-to-cancel — via Stimulus's native keyboard filter (`event:` passes straight through). See [Keyboard triggers](#keyboard-triggers-enter-to-submit--escape-to-cancel). |
@@ -385,15 +678,17 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
385
678
  | `busy_on(:save)` | Mark any element so it carries `data-reactive-busy` **only while `save` is in flight** — a spinner styled with pure CSS, zero Ruby. See [Loading states](#declarative-loading-states-loading--disable_with). |
386
679
  | `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
387
680
  | `on_client(:click, js.toggle("#menu"))` | **Client-only** trigger: applies declared DOM ops with ZERO round trip — no token, no POST, ever. Takes the same `window:`/`once:`/`outside:` modifiers. See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). |
388
- | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), `dispatch`, `submit` (requestSubmit the target's own form), `paste_into` (read the clipboard into a field, gesture-gated), and `persist_state`/`persist_clear` (the `reactive_persist` draft) — chainable. |
681
+ | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:` and `expanded:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names; `toggle_attr` also flips between two values), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), `dispatch`, `submit` (requestSubmit the target's own form, optionally through a `submitter:`), `paste_into` (read the clipboard into a field, gesture-gated), and `persist_state`/`persist_clear` (the `reactive_persist` draft) — chainable. |
389
682
  | `reactive_field(:param, **attrs)` | The attribute hash that binds a control to an action param (no magic `name:`) — spread onto any control: `input(**reactive_field(:value, value: @record.name))`, `select(**reactive_field(:status)) { … }`. |
390
683
  | `reactive_text(:name, initial)` | Mirror a compute output (or a declared input) into a **text node** — a live preview heading, a character counter, `"Hello, {name}"` — via `textContent` (XSS-safe). The text sibling of `reactive_field`; carries no `name`, so it's never POSTed. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
391
684
  | `reactive_show(if:/if_any:/unless:)` | **Value-conditional visibility** (the `x-show`/`data-show` case): spread onto the element to show/hide — it toggles `hidden` from the fields' **current values**, client-only, zero round trip. One conditions language: a **Hash is an AND**, an **Array is membership**, a **Range is a threshold**, `if_any:` is OR-of-AND, `unless:` negates. `reactive_values` computes first paint; `disable:` disables a hidden section's controls. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
685
+ | `reactive_enable(if:/if_any:/unless:)` | The sibling of `reactive_show` that flips the element's **own `disabled`** (a button, a fieldset) instead of `hidden` — same conditions, same `reactive_values` first paint. `button(**reactive_enable(if: { "ids[]" => { checked: 1.. } }))`. See [Bulk selection](#bulk-selection-reactive_select_all-reactive_count-reactive_enable). |
686
+ | `reactive_select_all("ids[]")` / `reactive_count("ids[]")` | A checkbox **group**'s header box (ticks/unticks every owned box; shows checked / indeterminate / unchecked) and its ticked count as text — re-synced on every change and when rows are appended or removed. See [Bulk selection](#bulk-selection-reactive_select_all-reactive_count-reactive_enable). |
392
687
  | `reactive_show_targets(:field, "#id" => value)` | **Cross-root visibility**: the component that owns the field declares which **outside**, id-allowlisted elements it governs (a nav tab, a panel in another pane) — the visibility parallel of `mirror:`. Spread on the **root** via `mix(reactive_root, …)`, **once per root** — several fields go in one call via the hash form. The value uses the same `where`-style vocabulary (`"advanced"`, `%w[a b]`, `10..`); a `"#id"` **key** takes a full conditions Hash for a **multi-field** predicate (`"#warn" => { if: { type: "trade", price: ..0 } }`). Id selectors only (raise at render + client warn-skip); toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
393
688
  | `reactive_persist(key:, ttl: 7.days)` | **Client-only drafts**: spread on the **root** (once) and the generic controller keeps a `localStorage` draft of every **owned** control — debounced write on `input`, immediate on `change`, flushed on disconnect, restored into **blank** controls on the next connect (`restore: :always` lets the draft win), cleared by a successful Turbo submit / `ttl` / `js.persist_clear`. Never hidden/file/password; `reactive_persist_skip` opts a control out; `fields:` narrows. See [Client-only drafts](#client-only-drafts-reactive_persist). |
394
689
  | `js.persist_state(step: 2)` / `js.persist_clear` | The draft ops (actor-only): merge a flat state bag into the draft (restored as `data-reactive-persist-state` + the `reactive:persist-restored` event) / forget the draft. |
395
690
  | `reactive_filter(:field, option: nil, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root and name the **field** that drives it — `reactive_filter(:q)` compiles `:q` to `[name="q"]` (scope-aware) and typing shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. `option:` defaults to `[role=option]`; optional `group:` collapses an all-hidden group header; `empty:` reveals a no-matches node. `input:` is the escape hatch — a raw CSS selector for a **name-less** driving input (`input: "#tags_query"`), the form-builder case. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). |
396
- | `reactive_listnav("[role=option]")` | The **standalone** combobox keyboard wiring (Arrow/Enter/Escape) for an input that fires **no action** — the preload-and-filter case. Same behavior as `on(…, listnav:)`, minus the POST. |
691
+ | `reactive_listnav("[role=option]")` | The **standalone** combobox keyboard wiring (Arrow/Enter/Escape) for an input that fires **no action** — the preload-and-filter case. Same behavior as `on(…, listnav:)`, minus the POST. `reactive_listnav(focus: true)` is the **roving-focus** variant for a `role="menu"` container: Arrow Down/Up move real focus among `[role=menuitem]` (wrapping), Home/End jump to the edges; Enter/Space stay native and Escape is yours to bind. |
397
692
  | `reactive_tags(:tags)` | **Tag-chip input** (the combobox/tags widget): spread onto the root and name the hidden field that stores the **comma-joined** value — the client maintains that field + the chip list entirely client-side (form state, zero round trips), rebuilding chips from your server-owned `<template>`. Composes with `reactive_filter` (type to narrow) and `reactive_listnav` (Enter picks the highlighted option). `name:` is the escape hatch — a **verbatim** wire name (`name: "user[tags]"`, never re-scoped), the form-builder case. See [Tag-chip input](#tag-chip-input-reactive_tags). |
398
693
  | `reactive_tags_add` / `reactive_tags_option(tag)` / `reactive_tags_remove(tag)` | The tags triggers, all **client-only**: `reactive_tags_add` on the query input adds the typed text on Enter (mix it **after** `reactive_listnav`; Enter never submits the enclosing form); `reactive_tags_option` makes a preloaded suggestion add its declared tag on click; `reactive_tags_remove` is a chip's remove button (no arg inside the template — the client fills the tag per chip). |
399
694
  | `reactive_compute :name, inputs: { title: :string, qty: :number }, outputs:` | **Typed** inputs: a `:string` reaches the JS reducer raw, a `:number` is coerced through `Number`, a `:boolean` is a checkbox's checked state. The array form (`inputs: %i[a b]`) stays all-numeric; the **permit-style** form (`inputs: [:qty, title: :string]`) mixes both — bare symbols default `:number`, a trailing hash types the exceptions. `outputs:` is the field allowlist; a reducer-result key also paints any owned `reactive_text` node by presence and any `mirror:` id, so an `outputs:` entry that exists only to reach a text node is redundant (harmless — a widening). |
@@ -583,6 +878,12 @@ action :save, params: {invoice: {date: :string, status: :string}}
583
878
  > `invoice[…]` (any `Form(model:)`-style form), nest the schema under `invoice:`
584
879
  > to match. When in doubt, read a field's real `name` attribute and shape the
585
880
  > schema to it.
881
+ >
882
+ > The exception is `reactive_scope :invoice`: the endpoint expands the names and
883
+ > then peels that one level, on the JSON body and the multipart one alike, so the
884
+ > flat schema is the one to declare. Bare params posted beside the scoped fields
885
+ > (a trigger's `on(:save, note: "x")`) stay at the top level next to them; as
886
+ > on the client, an explicit `on(...)` param wins over a field of the same name.
586
887
 
587
888
  **Nested reactive components compose.** A reactive component rendered inside
588
889
  another is its own root — field collection stops at nested
@@ -823,9 +1124,11 @@ controller (re-scan on `connect`); an in-place **morph** keeps the element
823
1124
  connected and fires no Stimulus lifecycle, so the client also re-scans on
824
1125
  `turbo:morph-element` after the morph writes fresh `default*` attributes. So a
825
1126
  `reply.morph` save renders the field with the new value as its **new default**,
826
- and the badge clears with no reload. (Turbo 8 morph preserves a focused field's
827
- in-progress value while writing the fresh defaults — the post-morph re-scan is
828
- what keeps the root count honest in that state.)
1127
+ and the badge clears with no reload — for a focused field, once the saved value
1128
+ matches what is in the box. (A morph keeps a focused field's
1129
+ in-progress value while writing its fresh default — the client does that, issue
1130
+ #338, since Turbo's stream morph would overwrite it — and the post-morph re-scan
1131
+ is what keeps the root count honest in that state.)
829
1132
 
830
1133
  **`reactive_dirty warn_unsaved: true`** arms a navigate-away guard gated on the
831
1134
  **live** dirty count: `beforeunload` (a real browser unload) and
@@ -892,7 +1195,10 @@ button(**on_client(:click, js
892
1195
  ```
893
1196
 
894
1197
  - **`set_attr(to, name, value)` / `remove_attr(to, name)` / `toggle_attr(to, name)`**
895
- mutate an attribute. The **attribute name is allowlisted, enforced twice** — at
1198
+ mutate an attribute. `toggle_attr(to, name, on, off)` (#271) flips **between two
1199
+ values** instead of toggling presence — `toggle_attr("#trigger", "aria-expanded",
1200
+ "true", "false")`; an absent attribute becomes `on`, and one value or two equal
1201
+ values raise at build time. The **attribute name is allowlisted, enforced twice** — at
896
1202
  build time in Ruby (an offending name raises) and again in the client
897
1203
  interpreter (a hand-built op is warned and skipped). Refused: **event handlers**
898
1204
  (`on*` → XSS), **URL-bearing** names (`href`, `src`, `srcdoc`, `action`,
@@ -920,7 +1226,15 @@ button(**on_client(:click, js
920
1226
  from `on_client` / `reply.js` / a reducer's `$ops`, refused in
921
1227
  `broadcast_to(js:)` (a broadcast would force-submit every subscriber's form).
922
1228
  Binding a submit op to the `submit` event itself raises at render — it would
923
- re-fire itself forever.
1229
+ re-fire itself forever. **`submitter:`** (a CSS selector, resolved with the
1230
+ op's own scoping) submits *through* that control —
1231
+ `form.requestSubmit(submitter)` — so the request carries its `name=value`:
1232
+ `js.submit("#bulk", submitter: "#delete-submit")` posts `action=delete`. It
1233
+ composes with `on_client(…, confirm:)` (a cancelled confirm submits nothing).
1234
+ A submitter that is not a submit control of that form warns and falls back to
1235
+ a plain `requestSubmit()`. Note that a selector target resolves *inside* the
1236
+ root, so when the form **is** the reactive root, use the default target:
1237
+ `js.submit(submitter: "#delete-submit")`.
924
1238
  - **`paste_into(to)`** reads the clipboard into a field on a **user gesture**
925
1239
  (issue #228): `navigator.clipboard.readText()`, then the field gets the text
926
1240
  through the **normal `input` pipeline** — `.value` is set, a bubbling `input`
@@ -940,6 +1254,14 @@ button(**on_client(:click, js
940
1254
  (a broadcast that reads every subscriber's clipboard would be hostile). The
941
1255
  op is async fire-and-forget — chained siblings apply immediately, never
942
1256
  waiting for the read.
1257
+ - **`expanded:`** on `show`/`hide`/`toggle` (#271) keeps a disclosure trigger's
1258
+ `aria-expanded` honest: `js.toggle("#menu", expanded: "#trigger")` writes the
1259
+ op's **intended** state (`show` → `"true"`, `hide` → `"false"`, `toggle` → the
1260
+ state after the flip: `"true"` when the target was hidden, `"false"` when it
1261
+ was visible) onto the target — set before any transition runs. It resolves
1262
+ with the op's own scoping (`:root`, a root-scoped selector, or document-wide
1263
+ under `global: true`). Pair it with one disclosure target; with several, the
1264
+ last one wins.
943
1265
  - **`transition: { during:, from:, to: }`** on `show`/`hide`/`toggle` animates the
944
1266
  visibility flip: `during`+`from` are applied, then `from`→`to` swaps on the next
945
1267
  frame, and the helper classes are cleaned up on `animationend` (with a timeout
@@ -950,6 +1272,32 @@ button(**on_client(:click, js
950
1272
  modifiers: the dropdown above closes on any click outside the component, and
951
1273
  window-bound triggers never `preventDefault`, so links elsewhere keep working.
952
1274
 
1275
+ **Several bindings on one element (#271).** Each `on_client` call emits one
1276
+ self-describing **binding record** — its descriptor, flags, `confirm:` and ops —
1277
+ so two calls compose through `mix`. The descriptors token-join; the records
1278
+ space-join (spaces inside a record ride as `\u0020`); the client runs only the
1279
+ record whose descriptor matches the firing event (event type, key filter,
1280
+ window-boundness):
1281
+
1282
+ ```ruby
1283
+ div(**mix(reactive_root,
1284
+ on_client(:click, js.hide("#menu", expanded: "#trigger"), outside: true),
1285
+ on_client("keydown.esc", js.hide("#menu", expanded: "#trigger").focus("#trigger"))))
1286
+ # data-action="click@window->reactive#runOps keydown.esc->reactive#runOps"
1287
+ # data-reactive-ops-param='{"on":"click","window":true,"outside":true,"ops":[…]} {"on":"keydown.esc","ops":[…]}'
1288
+ ```
1289
+
1290
+ An outside guard or a `confirm:` belongs to its own binding only — it never
1291
+ gates a sibling. Two element-bound `:click` calls on the same `<button>` still
1292
+ collide on the forced `type="button"` (`mix` renders `type="button button"`):
1293
+ chain the ops into one call instead.
1294
+
1295
+ **An accessible menu with no custom JS.** Pair the above with
1296
+ `reactive_listnav(focus: true)` on the `role="menu"` list — Arrow Down/Up rove
1297
+ real focus among `[role=menuitem]` (wrapping), Home/End jump to the edges — and
1298
+ the whole WAI-ARIA menu-button pattern is declared ops. See the
1299
+ [client-only ops example](https://phlex-reactive.zoolutions.llc/docs/example-client-ops).
1300
+
953
1301
  **The general autosubmit story.** With `submit` in the vocabulary, the classic
954
1302
  `onchange="this.form.requestSubmit()"` filter form is one declared line — no
955
1303
  bespoke controller, no reactive action, and Turbo Drive turns the resulting
@@ -1070,7 +1418,10 @@ end
1070
1418
  length** — exact (`{ length: 6 }`) or an Integer Range (`{ length: 6.. }`,
1071
1419
  `{ length: 4..8 }`). Length counts **codepoints** on both sides (Ruby
1072
1420
  `String#length`, client `[...value].length`), so multibyte input agrees; a
1073
- blank field has length 0. `unless:` **negates** and composes with `if:`.
1421
+ blank field has length 0. **`{ checked: … }` counts the ticked boxes of a
1422
+ checkbox group** — `{ "ids[]" => { checked: 1.. } }` (see
1423
+ [Bulk selection](#bulk-selection-reactive_select_all-reactive_count-reactive_enable)).
1424
+ `unless:` **negates** and composes with `if:`.
1074
1425
  Never an expression — every term is a declared literal, so there is no eval
1075
1426
  surface. A blank/non-numeric value fails a numeric term **closed** (hidden).
1076
1427
  - **OR-of-AND** — `if_any:` takes an array of AND-hashes (`if_any: [{ director:
@@ -1150,6 +1501,70 @@ field; a missing owned field reads as blank — fail-closed). Every referenced
1150
1501
  field must be owned by the declaring root; a target whose fields are all
1151
1502
  unowned is left alone, like the single-field skip.
1152
1503
 
1504
+ ### Bulk selection (`reactive_select_all`, `reactive_count`, `reactive_enable`)
1505
+
1506
+ A list with a checkbox per row, a "select all" header, a "Delete (2)" count and
1507
+ actions that stay disabled until something is ticked — with no per-list
1508
+ JavaScript controller:
1509
+
1510
+ ```ruby
1511
+ class PostsTable < Phlex::HTML
1512
+ include Phlex::Reactive::ClientBindings
1513
+
1514
+ def initialize(posts:) = @posts = posts
1515
+
1516
+ # First paint: nothing ticked (an Array of checked values, or a count).
1517
+ def reactive_values = { "ids[]" => [] }
1518
+
1519
+ def view_template
1520
+ div(**reactive_root) do
1521
+ form(id: "bulk", action: "/posts/bulk", method: "post") do
1522
+ input(type: "checkbox", **reactive_select_all("ids[]")) # header
1523
+ @posts.each do |post|
1524
+ label { input(type: "checkbox", name: "ids[]", value: post.id); plain post.title }
1525
+ end
1526
+
1527
+ span(**reactive_count("ids[]")) { "0" } # ticked count
1528
+ fieldset(**reactive_enable(if: { "ids[]" => { checked: 1.. } })) { bulk_fields }
1529
+ button(type: "button",
1530
+ **mix(reactive_enable(if: { "ids[]" => { checked: 1.. } }),
1531
+ on_client(:click, js.submit("#bulk", submitter: "#delete-submit"),
1532
+ confirm: "Delete the selected posts?"))) { "Delete" }
1533
+ button(type: "submit", name: "bulk_action", value: "delete", hidden: true, id: "delete-submit")
1534
+ end
1535
+ end
1536
+ end
1537
+ end
1538
+ ```
1539
+
1540
+ - **`reactive_select_all(group)`** — the header box. Its edit ticks or unticks
1541
+ every **owned** box named `group` and dispatches `input` + `change` on each one it
1542
+ flips, so computes, shows and completions re-run. Its own state follows the
1543
+ group: **checked** when all are ticked, **indeterminate** when some are,
1544
+ unchecked when none are. Give the header no `name` (it would post).
1545
+ - **`reactive_count(group)`** — writes the ticked count via `textContent`
1546
+ (change-guarded). Seed the first paint in its block.
1547
+ - **`{ checked: n }` / `{ checked: 1.. }`** — a term in the shared conditions
1548
+ language: how many owned boxes of the group are ticked, with the same Integer
1549
+ and Range shapes as `length:`. It works in `reactive_show`, `reactive_enable`,
1550
+ `reactive_on_complete`, `reactive_show_targets` and a conditional `confirm:`,
1551
+ and it is evaluated on the server for first paint from `reactive_values` (an
1552
+ Array of the checked values is counted; an Integer is the count).
1553
+ - **`reactive_enable(if:/if_any:/unless:)`** — `reactive_show`'s sibling: the
1554
+ same conditions, the same first paint (`disabled:`) and the same re-evaluation
1555
+ triggers, but it flips the element's own `disabled`. An explicit `disabled:`
1556
+ wins.
1557
+ - **Rows added or removed later** (a Turbo stream `append`/`remove`, a morph)
1558
+ re-sync the header, the count, `reactive_enable` and `reactive_show` (and its targets): a root with a group binding
1559
+ watches its subtree and re-syncs when a checkbox is added or removed.
1560
+ - **Ownership** is the usual rule (#15): a nested reactive root's boxes are
1561
+ never counted or flipped by the outer root.
1562
+ - The group is a field name resolved like any `reactive_show` field: a bare
1563
+ name takes `reactive_scope`, a bracketed one (`"ids[]"`) is used verbatim.
1564
+ - **`js.submit(to, submitter:)`** submits the form through the hidden submit
1565
+ button, so the POST carries `bulk_action=delete` (see the
1566
+ [op vocabulary](#client-only-ops-on_client--js--zero-round-trips)).
1567
+
1153
1568
  ### Client-only drafts (`reactive_persist`)
1154
1569
 
1155
1570
  "Don't make me start over": a public application form, a multi-step wizard,
@@ -1194,7 +1609,11 @@ wire attr: `data-reactive-persist='{"key":"village-apply","ttl":604800,"debounce
1194
1609
  `reactive_show` section, `reactive_on_complete` (armed, never fired),
1195
1610
  `reactive_filter` and a `reactive_compute` root all read the restored values
1196
1611
  on first paint — no synthetic events. A morph or broadcast re-render is
1197
- server truth and is **never** re-restored.
1612
+ server truth and is **never** re-restored. (With the opt-in
1613
+ [split client](#the-split-client-opt-in-a-smaller-core-features-on-demand)
1614
+ the first `reactive_persist` root of a page load restores a moment after it
1615
+ connects, once the draft module has arrived; that section lists what holds
1616
+ in between.)
1198
1617
  - **`restore: :blank`** (default) — a draft value lands only in a control the
1199
1618
  server rendered **blank**, so a 422 re-render's submitted values beat an
1200
1619
  older draft. `restore: :always` lets the draft win.
@@ -1926,7 +2345,8 @@ def add(item:) = reply.replace.stream(Totals.update(@order)) # mul
1926
2345
 
1927
2346
  # Per-field reactive editing (a "spreadsheet" grid): a debounced save fires
1928
2347
  # while the user is still typing/tabbing. Morph in place so the focused <input>
1929
- # and its in-progress value survive the re-render (issue #28). Note the action is
2348
+ # and its in-progress value survive the re-render (issues #28, #338) — even when
2349
+ # the render differs (the model strips "Hello " to "Hello"). Note the action is
1930
2350
  # named `update`, yet `reply.morph` is unambiguous — the verb is on `reply`:
1931
2351
  def update(name:) = (@row.update!(name:); reply.morph)
1932
2352
 
@@ -1943,7 +2363,7 @@ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams
1943
2363
  | Builder | Reply |
1944
2364
  |---|---|
1945
2365
  | `reply.replace` / `reply.update(morph: false)` | re-render in place (default; `replace` swaps the whole element via outerHTML, `update` swaps only the inner HTML) |
1946
- | `reply.morph` / `reply.replace(morph: true)` / `reply.update(morph: true)` | re-render in place via Idiomorph (`method="morph"`) — preserves the focused `<input>` + caret; for per-field reactive editing (`replace` #28; `update` #113) |
2366
+ | `reply.morph` / `reply.replace(morph: true)` / `reply.update(morph: true)` | re-render in place via Idiomorph (`method="morph"`) — preserves the focused `<input>`/`<textarea>`, its caret AND what was typed in it (the render's value becomes its new default; other fields take the server's value; `data-reactive-morph-value` on a field lets the morph overwrite it while focused, #338); for per-field reactive editing (`replace` #28; `update` #113) |
1947
2367
  | `.also(target => content, …)` | **UPDATE** (inner HTML) companion elements by DOM id; `content` is a plain string (escaped) or a Phlex component. The argument *type* picks the action — pairs mean `update` |
1948
2368
  | `.also(component, morph: false)` | **REPLACE** another Streamable component at its own `#id` (`morph: true` morphs in place). A component argument means `replace` |
1949
2369
  | `.flash(level, content, target: …)` | append a flash; `content` is a plain string (escaped, wrapped in a level-carrying `<div>` — see [Flash levels](#flash-levels)) or a Phlex component (rendered verbatim; off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
@@ -2103,6 +2523,134 @@ to ship a matching shell element. The client re-fetches the real content both on
2103
2523
  connect AND after a Turbo page-refresh **morph** (which re-shows the shell while
2104
2524
  keeping the element connected), so a lazy component survives a `turbo:reload`.
2105
2525
 
2526
+ **Load on first use** — `on:` defers the *request itself* until an event
2527
+ reaches the shell, or until it scrolls into view (issue #276):
2528
+
2529
+ ```ruby
2530
+ reactive_lazy on: "panel:opened" # a DOM event on (or bubbling into) the shell
2531
+ reactive_lazy on: :visible # IntersectionObserver, no DOM event involved
2532
+ reactive_lazy on: { visible: "200px" } # …with a rootMargin
2533
+ ```
2534
+
2535
+ `:visible` fires when the shell first enters the viewport grown by the
2536
+ rootMargin, so a margin can load it slightly before it scrolls into view. In an
2537
+ engine without `IntersectionObserver` a `:visible` shell materializes right
2538
+ after connect, so it still loads, just not lazily.
2539
+
2540
+ The shell makes no request on page load and materializes exactly once; the
2541
+ real render has no trigger, so later events are no-ops. It carries the
2542
+ identity token (no expiry, unlike the defer token), so it works on a page left
2543
+ open past `defer_token_ttl`. The framework-owned `__materialize` act rides the
2544
+ action endpoint, runs no action, shares the defer endpoint's authorization
2545
+ step (registered error → 403, `render?` false → the shell stays), and answers
2546
+ 403 for any component that isn't `reactive_lazy(on:)`. Like the defer endpoint
2547
+ it is a read: it skips `around_actions`, `verify_authorized` and the pgbus
2548
+ connection-id scope, so enforce tenant scoping or rate limits in your base
2549
+ controller or in the render.
2550
+
2551
+ A failed load is not retried on its own. A Turbo morph of the root handles the
2552
+ rest: real content morphed back into the shell re-materializes at once (one
2553
+ request), and a shell that is still unloaded is re-armed, which is also a
2554
+ failed load's retry path. **Give the shell the real root's tag** (`tag: :ul`
2555
+ for a `<ul>` root): with the default `<div>` shell on a `<ul>`/`<tr>` root,
2556
+ Turbo swaps the node instead of morphing it, and an already-open panel shows
2557
+ its skeleton until its event fires again. With matching tags, a page that
2558
+ refresh-morphs pays one request per loaded `on:` component per refresh;
2559
+ `data-turbo-permanent` on the real root opts it out. See the
2560
+ deferred-rendering docs page.
2561
+
2562
+ **Reuse across page views** — `cache:` makes the real render a GET the browser
2563
+ may keep privately, so a fragment that is the same on every page (a menu, an
2564
+ account summary) is not fetched and rendered again on every page view (issue
2565
+ #277):
2566
+
2567
+ ```ruby
2568
+ class AccountMenu < ApplicationComponent
2569
+ include Phlex::Reactive::Component
2570
+
2571
+ reactive_lazy cache: { max_age: 10.minutes } # combine with on: / tag: freely
2572
+
2573
+ def id = "account-menu"
2574
+ def reactive_cache_viewer = Current.user&.id # who this render is for
2575
+ def reactive_cache_version = Current.user&.shortcuts_updated_at # optional: busts the URL
2576
+ def view_template = Current.user.shortcuts.each { |s| a(href: s.url) { s.label } }
2577
+ end
2578
+ ```
2579
+
2580
+ The shell carries a stable signed URL (`/reactive/fragment/<signed id>?v=…&u=…`)
2581
+ instead of a per-render token; the client GETs it and the endpoint answers
2582
+ `Cache-Control: max-age=600, private` with an ETag. With `on:` this is "load on
2583
+ first open, reuse across page views" with no app JavaScript, and a refresh morph
2584
+ re-materializes from the browser cache instead of costing a request.
2585
+
2586
+ The shell stays in the page in the fragment's place: a record-less `on:` +
2587
+ `cache:` shell with a viewer and a version is about 290 B gzipped (it carries
2588
+ the URL and no token). Defer what is clearly bigger than that — roughly 1 KB
2589
+ gzipped or more — or slow to render; a dozen links is cheaper inline. See the
2590
+ deferred-rendering page for the numbers.
2591
+
2592
+ What keeps a private cache safe:
2593
+
2594
+ - **Never `public`.** `max_age` is capped by
2595
+ `Phlex::Reactive.fragment_cache_max_age_limit` (1 hour). Every error — a 4xx,
2596
+ a 204, and anything your base controller answers first (401, a redirect) — is
2597
+ `no-store`.
2598
+ - **One viewer's copy never reaches the next.** By default the reply says
2599
+ `Vary: Cookie`, so a copy is reused only while the browser sends identical
2600
+ cookies. Rails' cookie session store issues a new session cookie on every
2601
+ response, so there that means "within one page" (a refresh morph), not across
2602
+ page views. Define `reactive_cache_viewer` to key the URL on the viewer
2603
+ instead: the endpoint re-computes it in the requesting session and makes the
2604
+ reply cacheable only when the URL names that viewer. Return everything the
2605
+ render depends on besides the identity and the version (user, tenant, locale).
2606
+ `u` is an opaque keyed digest. A blank value (`nil`, `false`, `""`, an Array
2607
+ with a blank part) names nobody and is never a shared "anonymous" key: that
2608
+ render falls back to `Vary: Cookie`, so `Current.user&.id` is safe as is.
2609
+ Return a value unique per viewer: it is expanded like a cache key, so `0`,
2610
+ `"0"` and `[0]` are one viewer, and `true` — or an unsaved record — is
2611
+ everyone (return `nil` for guests).
2612
+ - **Opt-in only.** The fragment id is MACed under its own purpose: it is not
2613
+ an identity or defer token and those are not fragment ids (400 either way). A
2614
+ component without `cache:` is not reachable over GET (404). The id names no
2615
+ viewer, but it carries the signed state / record GlobalID in a URL — signed,
2616
+ not encrypted, so keep secrets out of `reactive_state`.
2617
+ - **A read.** No action, no `around_actions`, no transaction; `v` and `u` only
2618
+ shape the browser's cache key. Authorize in the render, as for any lazy
2619
+ component. A cacheable reply carries no `Set-Cookie`; a request during which
2620
+ any callback (before, around or after) changed the session, set or consumed a
2621
+ flash, minted a CSRF token, or wrote a cookie keeps that write and is answered `no-store` instead —
2622
+ so an app that writes the session on every request (Devise `timeoutable`) gets
2623
+ no caching. Session writes made outside the controller (Rack middleware, a
2624
+ CSP nonce generator that writes the session) are not seen, and are lost on a
2625
+ cacheable reply. The endpoint itself never reads
2626
+ the flash, so a notice waiting for the next page view survives a fragment
2627
+ load. A filter may tighten the policy (`no_store`, `expires_now`, a shorter
2628
+ `max-age`, `must-revalidate`), never loosen it. The ETag is the body only —
2629
+ `etag { }` blocks are not applied.
2630
+ - **No CSRF tokens.** A render that embeds a form authenticity token or
2631
+ `csrf_meta_tags` is served `no-store` with a warning rather than cached.
2632
+ - **No CSP nonces.** A `nonce="…"` inside a cached fragment is replayed from the
2633
+ cache and will not match the page's.
2634
+ - **Nested lazy components render eagerly inside the fragment.** The fragment
2635
+ is a real render, so a `reactive_lazy` child (whatever its own `on:`/`cache:`)
2636
+ renders its template as part of the parent's copy: no expiring defer token,
2637
+ but under the parent's URL, viewer key and `max_age` — so the parent's viewer
2638
+ and version must cover what the child varies on.
2639
+
2640
+ With `on:`, a `cache:` component loads on the defer lane rather than the action
2641
+ pipeline: `reactive:before-dispatch` still fires and can veto it (a vetoed
2642
+ shell, cached or not, ignores later events until the next morph), but a failed
2643
+ load emits `reactive:error` (`kind: "defer"`) with a `retry()`. A reply that was
2644
+ redirected or is not a turbo-stream is a failed load, never rendered.
2645
+
2646
+ Within `max_age` the browser does not ask the server, so a revoked permission
2647
+ or a sign-out is not seen until the copy expires: keep `max_age` short for
2648
+ sensitive content and send `Clear-Site-Data: "cache"` on sign-out. If you move
2649
+ the endpoint (`Phlex::Reactive.fragment_path`), render
2650
+ `<meta name="phlex-reactive-fragment-path" content="…">` in your layout's
2651
+ `<head>`; without it the client refuses the URL (`reactive:error`), and
2652
+ `phlex_reactive:doctor` says so.
2653
+
2106
2654
  > **One edge case:** a `reply.defer(placeholder:)` shell (the action-driven,
2107
2655
  > not page-mount, form) carries no token of its own — the transient directive
2108
2656
  > owns its delivery. If a page is snapshotted by Turbo mid-defer and later
@@ -2258,6 +2806,22 @@ latency, veto a dispatch, or build retry UI **without forking the controller**:
2258
2806
  | `offline` | the browser was offline (`navigator.onLine === false`) when the action fired — the fetch was never sent | `retry` |
2259
2807
  | `network` | `fetch` itself rejected (DNS, connection reset, an interface drop mid-flight) — the server never saw the request | `retry` |
2260
2808
  | `apply` | the server processed the action successfully, but something AFTER the fetch threw (a malformed response, a Turbo render error) | no `retry` |
2809
+ | `feature` | a part of the client that loads on demand (a feature module, e.g. the `reactive_persist` drafts) could not be used on this root | `feature`, `phase`, `error`; no `action`, no `retry` |
2810
+
2811
+ `feature` only occurs with the opt-in
2812
+ [split client](#the-split-client-opt-in-a-smaller-core-features-on-demand):
2813
+ the default client has every feature inside its one file. The error carries
2814
+ the module's name and a `phase`: `"load"` (the import failed — it stays failed
2815
+ until the page is reloaded, because browsers cache a failed module),
2816
+ `"timeout"` (not there after 10 s; the root carried on without it, and it
2817
+ still connects if it arrives), `"connect"` (it threw while wiring the root) or
2818
+ `"detect"`. The root's other behavior keeps working, and the root is marked
2819
+ `data-reactive-error="feature"` each time it connects for as long as the page
2820
+ lives. What a missing feature costs: without `persist` a form keeps no draft;
2821
+ without `defer` a plain `reactive_lazy` shell never loads (its pending marker
2822
+ is cleared, so it does not shimmer forever) while a `reactive_lazy(on:)` shell
2823
+ still loads through the plain action. The 10 s is
2824
+ `<meta name="phlex-reactive-feature-timeout" content="ms">` in `<head>`.
2261
2825
 
2262
2826
  `apply` covers a throw in the controller's own post-fetch code — not a
2263
2827
  throwing listener on `reactive:applied` itself. Per the DOM spec,
@@ -2635,6 +3199,44 @@ bug). It emits:
2635
3199
  3. an inert `reactive:token` refresh, so the container's signed token rolls
2636
3200
  forward and the list is not act-once-only.
2637
3201
 
3202
+ #### Honest pending markup: `pending_template` (opt-in)
3203
+
3204
+ The markers dim a *live* row. When the pending state should be real markup — a
3205
+ "Queued" badge, a button that is genuinely gone rather than hidden by
3206
+ `pointer-events: none` — define `pending_template` on the row component (private
3207
+ is fine). `reply.pending` then also **replaces** each such row with it, just
3208
+ before marking it:
3209
+
3210
+ ```ruby
3211
+ class TransferRow < ApplicationComponent
3212
+ include Phlex::Reactive::Streamable
3213
+ include Phlex::Reactive::Component
3214
+
3215
+ def view_template
3216
+ li(**mix(reactive_attrs, id:)) { span { @transfer.label }; button(**on(:retry)) { "Retry" } }
3217
+ end
3218
+
3219
+ private
3220
+
3221
+ def pending_template
3222
+ li(**mix(reactive_attrs, id:)) { span { @transfer.label }; span(class: "badge") { "Queued" } }
3223
+ end
3224
+ end
3225
+ ```
3226
+
3227
+ A row without the hook is byte-identical to the marker-only reply.
3228
+
3229
+ - **Keep the root's `id`.** The settle targets it; a `pending_template` without
3230
+ `id="<the row's id>"` raises before anything is enqueued.
3231
+ - **Keep `reactive_attrs`** if the row carries its own token — the swapped row
3232
+ signs the row class's real name, so its next action verifies as usual.
3233
+ - **Un-pending is the settle's job.** Markers are attributes any settle strips;
3234
+ swapped markup is only undone by a settle that replaces (`s.replace`) or
3235
+ removes (`s.remove`) the row. A flash-only settle, `finish: true`, or a failed job clears the markers
3236
+ but leaves the pending markup in place.
3237
+ - It is a *template* method, unlike `deferred_placeholder` (which returns inner
3238
+ content for a shell the gem owns): here the row's own root is swapped.
3239
+
2638
3240
  | `reply.pending(...)` | |
2639
3241
  |---|---|
2640
3242
  | `records` | one record, an enumerable of records, or built Streamable components |
@@ -3294,6 +3896,26 @@ the groups the client cleared by name, without the `[]`, the way a form body
3294
3896
  sends them. **Token minting** —
3295
3897
  `reactive_token_for(component_or_class, payload = {})`.
3296
3898
 
3899
+ **System helpers** (`Phlex::Reactive::TestHelpers::System`, Capybara-gated) —
3900
+ `wait_for_reactive`, `have_reactive_value`, `have_reactive_text`, and request
3901
+ counting: `reset_reactive_requests!` baselines, `have_reactive_requests(n,
3902
+ kind: nil)` waits until nothing is in flight and asserts exactly `n` action
3903
+ (`kind: :action`) or deferred-render (`kind: :defer`) requests, and
3904
+ `reactive_request_count` returns `{ action:, defer: }`. The client keeps the
3905
+ totals on `<html data-reactive-requests>` under the verbose gate only. The
3906
+ matcher waits for requests in flight, not ones yet to start (a debounced
3907
+ trigger, a defer the reply is about to start): wait for the UI outcome first.
3908
+ A defer pushed over a pgbus stream makes no request and is not counted. A
3909
+ `reactive_lazy cache:` fragment GET counts as `:defer` per `fetch()` call, even
3910
+ when the browser cache answers it: assert "no network request" with Resource
3911
+ Timing (`transferSize === 0`).
3912
+
3913
+ ```ruby
3914
+ reset_reactive_requests!
3915
+ find("#open-panel").click
3916
+ expect(page).to have_reactive_requests(1)
3917
+ ```
3918
+
3297
3919
  > `verbose_errors` defaults ON in test (it changes only an error BODY, never a
3298
3920
  > status). Asserting an empty failure body? Set
3299
3921
  > `Phlex::Reactive.verbose_errors = false` in your setup.