phlex-reactive 0.13.3 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1097 -630
- data/README.md +638 -16
- data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
- data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
- data/app/javascript/phlex/reactive/core.js +181 -0
- data/app/javascript/phlex/reactive/core.min.js +4 -0
- data/app/javascript/phlex/reactive/core.min.js.map +11 -0
- data/app/javascript/phlex/reactive/early.js +175 -0
- data/app/javascript/phlex/reactive/early.min.js +4 -0
- data/app/javascript/phlex/reactive/early.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/bindings.js +1511 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/compute.js +541 -0
- data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/defer.js +726 -0
- data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
- data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/devtools.js +242 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/effects.js +362 -0
- data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
- data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/form.js +216 -0
- data/app/javascript/phlex/reactive/features/form.min.js +4 -0
- data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/hints.js +193 -0
- data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
- data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/persist.js +663 -0
- data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
- data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
- data/app/javascript/phlex/reactive/inspect.js +21 -9
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +85 -5580
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
- data/app/javascript/phlex/reactive/runtime.js +3690 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
- data/lib/phlex/reactive/component/dsl.rb +74 -2
- data/lib/phlex/reactive/component/helpers.rb +104 -28
- data/lib/phlex/reactive/component/lazy.rb +138 -2
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/component/selection.rb +71 -0
- data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
- data/lib/phlex/reactive/doctor.rb +166 -8
- data/lib/phlex/reactive/dormant.rb +66 -0
- data/lib/phlex/reactive/engine.rb +64 -3
- data/lib/phlex/reactive/fragment.rb +233 -0
- data/lib/phlex/reactive/inspector/report.rb +24 -3
- data/lib/phlex/reactive/inspector.rb +23 -2
- data/lib/phlex/reactive/js.rb +61 -12
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
- data/lib/phlex/reactive/param_schema.rb +68 -54
- data/lib/phlex/reactive/pending/markup.rb +128 -0
- data/lib/phlex/reactive/pending.rb +25 -21
- data/lib/phlex/reactive/show_conditions.rb +84 -56
- data/lib/phlex/reactive/streamable.rb +20 -2
- data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
- data/lib/phlex/reactive/test_helpers/system.rb +62 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +84 -0
- data/lib/tasks/phlex_reactive.rake +6 -2
- metadata +39 -1
data/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
|
-
|
|
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` (
|
|
139
|
-
|
|
140
|
-
apps — so browsers load the small file while
|
|
141
|
-
|
|
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
|
|
827
|
-
|
|
828
|
-
|
|
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.
|
|
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. `
|
|
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 (
|
|
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
|
|
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.
|