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
@@ -0,0 +1,3690 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ // Import the BARE specifier the engine already pins (phlex/reactive/confirm),
3
+ // NOT a relative "./confirm.js" (issue #57). Under importmap-rails + Propshaft
4
+ // the controller is served at its DIGESTED url; a relative sibling import is
5
+ // left untouched (Propshaft rewrites only RAILS_ASSET_URL(...), and the import
6
+ // map resolves ONLY bare specifiers), so "./confirm.js" resolves against the
7
+ // digested controller url → an undigested /assets/.../confirm.js that 404s, and
8
+ // the throwing import takes down every Stimulus controller on the page. The
9
+ // bare specifier resolves to the digested asset through the import map, and
10
+ // bundlers/bun resolve it the same way they already resolve
11
+ // "phlex/reactive/reactive_controller" (see tsconfig.json paths for the tests).
12
+ import { confirmResolver } from "phlex/reactive/confirm"
13
+ // The feature modules, imported STATICALLY (issue #305). Only the default
14
+ // entry uses these: there `__SPLIT__` is false (scripts/build_client.js
15
+ // defines it), every reference below sits in a `__SPLIT__ ? … : …` branch the
16
+ // minifier keeps, and the features end up in the same file as the runtime,
17
+ // called directly. The opt-in entry (core) is built with `__SPLIT__` true:
18
+ // those branches fold away, and its build resolves these five imports to an
19
+ // empty module, so core.min.js imports no feature until a root needs one.
20
+ //
21
+ // THE ORDER BELOW IS THE DEFAULT BUNDLE'S LAYOUT, not the connect order (that
22
+ // is #connectFeaturesNow's, persist first). The bundler lays the features out
23
+ // in import order, ahead of this file's own code, and gzip only matches text
24
+ // within 32 KB: a feature that repeats what the runtime also says (defer's
25
+ // fetch headers and meta reads, persist's editor selector, form's dirty
26
+ // marker) compresses against it only when it sits close to the runtime. This
27
+ // order was the smallest of a swap search over all of them (issue #310):
28
+ // ~450 B gzipped less than the table order. None of the features runs
29
+ // anything at import time, so the order changes bytes, never behaviour.
30
+ import * as bindingsFeature from "phlex/reactive/features/bindings"
31
+ import * as computeFeature from "phlex/reactive/features/compute"
32
+ import * as persistFeature from "phlex/reactive/features/persist"
33
+ import * as formFeature from "phlex/reactive/features/form"
34
+ import * as effectsFeature from "phlex/reactive/features/effects"
35
+ import * as devtoolsFeature from "phlex/reactive/features/devtools"
36
+ import * as hintsFeature from "phlex/reactive/features/hints"
37
+ import * as deferFeature from "phlex/reactive/features/defer"
38
+
39
+ // phlex/reactive/runtime — the reactive controller WITHOUT its feature
40
+ // modules (issue #275): the code both client entries share. It is not an
41
+ // entry itself and is never pinned; each entry is built with this file inside
42
+ // it. An app reaches it one of two ways, never both:
43
+ //
44
+ // phlex/reactive/reactive_controller the default: this runtime bundled with
45
+ // every feature in one file, built with
46
+ // __SPLIT__ false (issue #305): the
47
+ // features are imported statically and
48
+ // called directly, and connect inside
49
+ // connect(). It has no table and NO way
50
+ // to import one.
51
+ // phlex/reactive/core opt-in: this runtime plus the table of
52
+ // import() calls (core.js). A feature is
53
+ // imported the first time a root on the
54
+ // page needs it (see "Feature modules").
55
+ //
56
+ // The ONE generic controller behind every reactive Phlex component. It
57
+ // replaces the per-feature Stimulus controllers you'd otherwise hand-write
58
+ // for interactive components. A component declares its actions in Ruby (via
59
+ // Phlex::Reactive::Component); this controller binds DOM events to a single
60
+ // HTTP round trip and lets Turbo apply the re-rendered component back in
61
+ // (replace by default; method="morph" — Response.morph — preserves focus).
62
+ //
63
+ // Wire format (client -> server), POST <action path>, turbo-stream Accept:
64
+ // { token: "<signed identity>", act: "<action>", params: {...} } (JSON)
65
+ // (`act`, not `action`: `action` is a reserved Rails routing param.)
66
+ // The token is a MessageVerifier-signed { component, gid } — NO state is sent.
67
+ // When the root holds a chosen <input type="file">, the SAME payload is sent as
68
+ // multipart FormData instead (token/act flat, params bracketed, files appended)
69
+ // so an upload reaches the action (issue #34) — only the encoding differs.
70
+ // The response is a <turbo-stream> that replaces the component by its id.
71
+ //
72
+ // Server -> client live updates use the SAME element id, pushed over the
73
+ // stream transport (pgbus SSE / Action Cable) via the Streamable
74
+ // .broadcast_* methods — so a click and a background broadcast converge on
75
+ // one re-render unit.
76
+ //
77
+ // Custom turbo-stream action: the server tells the actor to full-navigate
78
+ // (e.g. the record's slug changed and the current URL is now dead). It rides a
79
+ // 200 turbo-stream — NOT an HTTP 3xx — so it never trips the response.redirected
80
+ // bail below (which still correctly catches real auth/CSRF redirects). Registered
81
+ // once on the Turbo global (no @hotwired/turbo import — the gem uses window.Turbo
82
+ // everywhere, and a named import is unreliable under importmap/esbuild).
83
+ export function registerReactiveVisit() {
84
+ const actions = window.Turbo?.StreamActions
85
+ if (!actions || actions["reactive:visit"]) return
86
+ actions["reactive:visit"] = function () {
87
+ const url = this.getAttribute("data-url")
88
+ if (url) window.Turbo.visit(url, { action: "advance" })
89
+ }
90
+ }
91
+
92
+ // Custom turbo-stream action: a TOKEN-ONLY refresh (issue #30). A partial
93
+ // update (Response.streams / reply.streams) re-renders only PART of a component
94
+ // — so there's no full-self replace to carry the next signed token. The server
95
+ // instead emits `<turbo-stream action="reactive:token" target="<id>"
96
+ // data-reactive-token-value="<fresh>">`. #perform's #extractToken already reads
97
+ // the token out of the response body for the NEXT queued request; this handler
98
+ // keeps the DOM in sync too, writing the attribute onto the root element so the
99
+ // `tokenValue` fallback stays fresh. It's a pure attribute set — no node is
100
+ // replaced — so a focused <input> + caret survive (the whole point: update a
101
+ // total cell without tearing down the field the user is typing in).
102
+ export function registerReactiveToken() {
103
+ const actions = window.Turbo?.StreamActions
104
+ if (!actions || actions["reactive:token"]) return
105
+ actions["reactive:token"] = function () {
106
+ const token = this.getAttribute("data-reactive-token-value")
107
+ const target = this.getAttribute("target")
108
+ if (!token || !target) return
109
+ const el = document.getElementById(target)
110
+ // Stimulus reads the token via the `token` value -> data-reactive-token-value.
111
+ if (el) el.setAttribute("data-reactive-token-value", token)
112
+ }
113
+ }
114
+
115
+ // Custom turbo-stream action: SERVER-PUSHED client DOM ops (issue #97). The
116
+ // server-side sibling of on_client's runOps — a reply (reply.<verb>.js(ops)) or
117
+ // a broadcast (Streamable.broadcast_js_to) emits
118
+ //
119
+ // <turbo-stream action="reactive:js" target="<optional root id>"
120
+ // data-reactive-ops="[[op, args], ...]"></turbo-stream>
121
+ //
122
+ // and Turbo invokes this handler with `this` bound to that <turbo-stream>
123
+ // element. It runs the ops through the SAME frozen CLIENT_OPS whitelist as
124
+ // runOps (client-side default-deny — an unknown op warns + is skipped), so a
125
+ // forged/stale ops attr can never break the page or execute anything off the
126
+ // vocabulary. NO token, NO fetch — a pure local DOM mutation.
127
+ //
128
+ // `target` (optional, an element id) scopes op resolution to that root: "@root"
129
+ // resolves to the target element itself and a selector resolves WITHIN it.
130
+ // Without a target, ops resolve document-wide (a broadcast op like
131
+ // add_class("#bell", ...) that isn't anchored to one component). The op stream
132
+ // is emitted AFTER all render streams in the reply (the endpoint appends it
133
+ // last), so focus("[name=next]") sees the freshly morphed DOM — Turbo applies
134
+ // streams in document order.
135
+ export function registerReactiveJs() {
136
+ const actions = window.Turbo?.StreamActions
137
+ if (!actions || actions["reactive:js"]) return
138
+ actions["reactive:js"] = function () {
139
+ const list = parseOps(this.getAttribute("data-reactive-ops"))
140
+ if (!list.length) return
141
+ const targetId = this.getAttribute("target")
142
+ // Issue #237: the server stamps the verbose gate on the stream element
143
+ // itself (verbose_errors), so document-scoped ops are diagnosable too.
144
+ const verbose = this.getAttribute("data-reactive-verbose") === "true"
145
+ // With a target: scope to that element (missing → no-op). Without: document.
146
+ const root = targetId ? document.getElementById(targetId) : null
147
+ if (targetId && !root) {
148
+ if (verbose) {
149
+ if (__SPLIT__) withFeature("devtools", (devtools) => devtools.missingRoot(targetId))
150
+ else devtoolsFeature.missingRoot(targetId)
151
+ }
152
+ return
153
+ }
154
+ applyOps(
155
+ list,
156
+ (args) => streamOpTargets(args, root),
157
+ verbose
158
+ ? (name, args) =>
159
+ __SPLIT__
160
+ ? withFeature("devtools", (devtools) => devtools.diagnoseStream(name, args, root))
161
+ : devtoolsFeature.diagnoseStream(name, args, root)
162
+ : undefined,
163
+ )
164
+ }
165
+ }
166
+
167
+ // --- Deferred and lazy renders (issues #165, #276, #277) ---------------------
168
+ // reply.defer, reactive_lazy and reactive_lazy(on:/cache:) live in the defer
169
+ // feature module (issue #275, features/defer.js). The core keeps only the
170
+ // `reactive:defer` stream action's REGISTRATION: such a stream can reach a
171
+ // page that has not loaded the module yet (the first reply.defer on a page
172
+ // with no lazy root), and it must not be lost. The <turbo-stream> element is
173
+ // handed over once the module is here — at once when it already is.
174
+ export function registerReactiveDefer() {
175
+ const actions = window.Turbo?.StreamActions
176
+ if (!actions || actions["reactive:defer"]) return
177
+ actions["reactive:defer"] = function () {
178
+ if (__SPLIT__) withFeature("defer", (defer) => defer.streamAction(this))
179
+ else deferFeature.streamAction(this)
180
+ }
181
+ }
182
+
183
+ // --- Around a stream render: effects and dismissing flashes (issues #100, #215) ---
184
+ // Both live in the effects feature module (issue #275, features/effects.js).
185
+ // The runtime keeps the ONE document-level turbo:before-stream-render listener
186
+ // (it fires for every <turbo-stream>: a reply AND a broadcast) and hands each
187
+ // event to the module, which wraps event.detail.render.
188
+ //
189
+ // In the default client the module is always here. The opt-in client may not
190
+ // have imported it yet when a stream first needs it; what happens then is that
191
+ // entry's business (core.js installs it: the stream's render waits for the
192
+ // import). The runtime only offers the event.
193
+ let streamRenderRegistered = false
194
+ let streamWithoutEffects = null
195
+
196
+ export function registerReactiveStreamRender() {
197
+ if (streamRenderRegistered) return
198
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
199
+ streamRenderRegistered = true
200
+ document.addEventListener("turbo:before-stream-render", (event) => {
201
+ // The opt-in entry sees every stream (it may hold one for a module the
202
+ // incoming content needs); with no entry hook, a loaded effects module
203
+ // wraps the stream directly — in the default entry, always.
204
+ if (!__SPLIT__) effectsFeature.wrap(event)
205
+ else if (streamWithoutEffects) streamWithoutEffects(event, featureModules.get("effects"))
206
+ else featureModules.get("effects")?.wrap(event)
207
+ })
208
+ }
209
+ // The names this registration had while it was two listeners.
210
+ export { registerReactiveStreamRender as registerReactiveDismiss, registerReactiveStreamRender as registerReactiveEffects }
211
+
212
+ // For the opt-in entry: what to do with a stream before its render — hold it
213
+ // for a module the incoming content needs. `handle(event, effects)` gets the
214
+ // loaded effects module (or undefined) and wraps the stream with it itself.
215
+ export function onReactiveStreamWithoutEffects(handle) {
216
+ streamWithoutEffects = handle
217
+ }
218
+
219
+ export function __resetReactiveStreamRenderForTest() {
220
+ streamRenderRegistered = false
221
+ }
222
+
223
+ // --- A morph keeps the focused field's value (issue #338) ---
224
+ // Turbo's stream morph (reply.morph, a broadcast morph) runs Idiomorph without
225
+ // ignoreActiveValue, so a field being typed in took the render's value: a
226
+ // normalised "Hello" over "Hello ", or the value as the request left. Two
227
+ // document listeners give a focused input/textarea inside a reactive root
228
+ // Idiomorph's own ignoreActiveValue behaviour, minus the stale default:
229
+ // before-morph-element — write the field's DEFAULT (its value attribute)
230
+ // from the new render ourselves; with the user's
231
+ // edit on it (the dirty-value flag) that never
232
+ // touches .value, so dirty tracking still re-scans
233
+ // against the saved value. A textarea's default is
234
+ // its text child, which Idiomorph still morphs.
235
+ // before-morph-attribute — cancel `value` on that field: Idiomorph honours it
236
+ // for the attribute AND the property. Nothing else
237
+ // (a root's token, a field's class) is held.
238
+ // data-reactive-morph-value on a field lets the morph write it while focused.
239
+ let morphFocusRegistered = false
240
+
241
+ function holdsTypedValue(el) {
242
+ return (
243
+ el === document.activeElement &&
244
+ (el.localName === "input" || el.localName === "textarea") &&
245
+ !el.hasAttribute("data-reactive-morph-value") &&
246
+ !!el.closest('[data-controller~="reactive"]')
247
+ )
248
+ }
249
+
250
+ export function registerReactiveMorphFocus() {
251
+ if (morphFocusRegistered) return
252
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
253
+ morphFocusRegistered = true
254
+ document.addEventListener("turbo:before-morph-element", (event) => {
255
+ const el = event.target
256
+ const next = event.detail?.newElement // absent when Turbo asks before a removal
257
+ if (!next || event.defaultPrevented || el.localName !== "input" || !holdsTypedValue(el)) return
258
+ const value = next.getAttribute("value")
259
+ if (value == null) el.removeAttribute("value")
260
+ else if (el.getAttribute("value") !== value) el.setAttribute("value", value)
261
+ })
262
+ document.addEventListener("turbo:before-morph-attribute", (event) => {
263
+ if (event.detail?.attributeName === "value" && holdsTypedValue(event.target)) event.preventDefault()
264
+ })
265
+ }
266
+
267
+ export function __resetReactiveMorphFocusForTest() {
268
+ morphFocusRegistered = false
269
+ }
270
+
271
+ // The framework-owned act a reactive_lazy(on:) shell sends (issue #276).
272
+ // Lockstep with Phlex::Reactive::Component::Lazy::MATERIALIZE_ACTION.
273
+ const LAZY_MATERIALIZE_ACTION = "__materialize"
274
+
275
+ // Offline CSS hook (issue #101). Mirror data-reactive-offline on
276
+ // document.documentElement from navigator.onLine, kept in sync by the window
277
+ // online/offline events — so an app can dim a save button or show a banner with
278
+ // PURE CSS and zero JS ([data-reactive-offline] .save { pointer-events: none }).
279
+ // Guarded on window (needed for addEventListener AND navigator) so importing the
280
+ // module in a non-browser (bun test) context is a no-op, and registered once
281
+ // (the online/offline listeners are NOT {once}, so a second registerReactiveActions
282
+ // call must not stack duplicates) — mirroring the dismiss guard + reset seam.
283
+ let offlineRegistered = false
284
+ export function registerReactiveOffline() {
285
+ if (offlineRegistered) return
286
+ if (typeof window === "undefined" || typeof document === "undefined") return
287
+ if (typeof window.addEventListener !== "function") return
288
+ offlineRegistered = true
289
+ // toggleAttribute(name, force) writes data-reactive-offline="" (a bare boolean
290
+ // attr the [data-reactive-offline] selector matches) or removes it — never the
291
+ // "true" string. navigator.onLine === false is the reliable direction (a false
292
+ // "online" is spec-permitted but rare, and this is only a presentational hook —
293
+ // the authoritative offline signal is the #perform gate, not this attribute).
294
+ // Fully defensive: a missing documentElement/toggleAttribute/navigator degrades
295
+ // to a no-op — a presentational hook must NEVER throw during bootstrap.
296
+ const sync = () => {
297
+ const root = document.documentElement
298
+ if (typeof root?.toggleAttribute !== "function") return
299
+ root.toggleAttribute("data-reactive-offline", globalThis.navigator?.onLine === false)
300
+ }
301
+ sync() // seed synchronously so first paint is correct
302
+ window.addEventListener("online", sync)
303
+ window.addEventListener("offline", sync)
304
+ }
305
+
306
+ export function __resetReactiveOfflineForTest() {
307
+ offlineRegistered = false
308
+ }
309
+
310
+ // Latency simulator dev aid (issue #102): features/devtools.js (issue #275). The
311
+ // core keeps the two places that decide whether the module is needed at all:
312
+ // the page opted into the console handle (the phlex-reactive-env meta), or a
313
+ // delay is stored for this tab. Read live per request, like the CSRF token.
314
+ const LATENCY_KEY = "phlex-reactive:latency"
315
+
316
+ function latencyStored() {
317
+ return typeof sessionStorage !== "undefined" && Boolean(sessionStorage.getItem(LATENCY_KEY))
318
+ }
319
+
320
+ // At registration: a page that can use the simulator gets the module now —
321
+ // effectively eager, in development only. No meta and no stored delay: no
322
+ // module, no global handle, nothing on the page.
323
+ //
324
+ // This runtime registers with Turbo while it is being evaluated, BEFORE the
325
+ // entry that imports it has run — before the default entry has handed the
326
+ // module over, before the opt-in entry has said where to import it from. So
327
+ // with neither in place this does nothing, and each entry calls it again
328
+ // once it has done its part (see reactive_controller.js and core.js).
329
+ export function registerReactiveDev() {
330
+ if (typeof document === "undefined") return
331
+ if (__SPLIT__ && !featureModules.has("devtools") && !FEATURES.get("devtools")[1]) return
332
+ const development = document.querySelector?.('meta[name="phlex-reactive-env"]')?.content === "development"
333
+ // (The default entry has the module in it: there is nothing to load, and
334
+ // attach() is idempotent, so the second call is harmless.)
335
+ if (!__SPLIT__) {
336
+ if (development) devtoolsFeature.attach()
337
+ } else if (development || latencyStored()) withFeature("devtools", (devtools) => development && devtools.attach())
338
+ }
339
+
340
+ // --- Global reactive-activity signal (issue #201) --------------------------
341
+ // A DOCUMENT-LEVEL count of in-flight reactive operations — the direct analogue
342
+ // of Turbo's progress bar, but for reactive round trips and deferred renders
343
+ // instead of navigations. Anything that starts an async reactive operation calls
344
+ // enterReactiveActivity(); when it settles (success OR failure, on every path) it
345
+ // calls exitReactiveActivity(). The count is exposed two ways so an app — or a
346
+ // system test — can key off "is the reactive layer settling?" without knowing
347
+ // about any individual root:
348
+ //
349
+ // * a marker on <html>: data-reactive-active present while count > 0 (CSS can
350
+ // drive a global spinner; code/tests can read it). A DISTINCT name from the
351
+ // per-root data-reactive-busy so a [data-reactive-busy] selector never also
352
+ // matches the document element.
353
+ // * events on document: reactive:busy on the 0 -> >0 edge, reactive:idle on the
354
+ // >0 -> 0 edge — EDGES ONLY (not once per op), each carrying { count }.
355
+ //
356
+ // It sums ACROSS all reactive roots (module-level, not per-controller) and across
357
+ // the two async lifecycles wired below:
358
+ // * dispatch — entered in #applyBusy (at ENQUEUE, so the queue wait counts too),
359
+ // exited in the settle closure #perform runs in its finally.
360
+ // * defer — entered/exited with the pendingDefers registry (set/delete), the
361
+ // ONE registry both the fetch (pull) and the stream (push) lane maintain — so
362
+ // the push lane stays balanced even though it clears its pending markers by a
363
+ // node swap, not clearDeferPending. A supersede is delete-then-set (net zero),
364
+ // which is correct: a fast typist's replaced defer is still "layer busy".
365
+ //
366
+ // compute-seed is deliberately NOT counted: recompute() is synchronous, so a seed
367
+ // is fully applied by the time the call returns — there is no async window to await
368
+ // (the "value settles a beat after a morph/seed" case the issue describes is
369
+ // covered by the System test helpers' re-resolve-by-id polling, not this counter).
370
+ export const ACTIVE_ATTR = "data-reactive-active"
371
+
372
+ let activityCount = 0
373
+
374
+ // Increment the global in-flight count; on the 0 -> 1 edge, mark <html> and fire
375
+ // reactive:busy. Fully defensive — a non-browser/test document with no
376
+ // documentElement/dispatchEvent still tracks the count and simply skips the DOM
377
+ // side effects (a global signal must never throw during bootstrap or a round trip).
378
+ export function enterReactiveActivity() {
379
+ activityCount++
380
+ if (activityCount === 1) syncReactiveActivity("reactive:busy")
381
+ }
382
+
383
+ // Decrement the global in-flight count, clamped at 0 so an unbalanced exit can
384
+ // never drive it negative (which would wedge the marker on forever). On the
385
+ // 1 -> 0 edge, clear the <html> marker and fire reactive:idle.
386
+ export function exitReactiveActivity() {
387
+ if (activityCount === 0) return
388
+ activityCount--
389
+ if (activityCount === 0) syncReactiveActivity("reactive:idle")
390
+ }
391
+
392
+ // The current in-flight count — a test seam and a runtime read (an app can gate an
393
+ // "unsaved changes" prompt on `reactiveActivityCount() > 0`).
394
+ export function reactiveActivityCount() {
395
+ return activityCount
396
+ }
397
+
398
+ // Test seam: reset the module-level counter (and clear the marker) between tests,
399
+ // since the module is imported once per bun run.
400
+ export function resetReactiveActivity() {
401
+ activityCount = 0
402
+ const root = typeof document !== "undefined" ? document.documentElement : null
403
+ root?.removeAttribute?.(ACTIVE_ATTR)
404
+ }
405
+
406
+ // Write the <html> marker from the current count and fire the edge event on
407
+ // document. Both sides are independently guarded so a partial document stub (a
408
+ // documentElement without toggleAttribute, or a document without dispatchEvent)
409
+ // degrades to a no-op rather than throwing.
410
+ function syncReactiveActivity(eventName) {
411
+ if (typeof document === "undefined") return
412
+ const root = document.documentElement
413
+ if (typeof root?.toggleAttribute === "function") {
414
+ root.toggleAttribute(ACTIVE_ATTR, activityCount > 0)
415
+ }
416
+ if (typeof document.dispatchEvent === "function" && typeof CustomEvent === "function") {
417
+ document.dispatchEvent(new CustomEvent(eventName, { detail: { count: activityCount } }))
418
+ }
419
+ }
420
+
421
+ // --- Reactive request totals (issue #279) ----------------------------------
422
+ // A running total of the reactive REQUESTS made, per kind — "action" (one per
423
+ // #perform fetch) and "defer" (one per performDeferFetch) — so a system test can
424
+ // assert "one request on first open, none after". The in-flight count above says
425
+ // whether the layer is settling; this says how much it did.
426
+ //
427
+ // The JSON on <html data-reactive-requests> IS the store (read-modify-write per
428
+ // request): a test reads it, and re-baselines by writing zeros, with no window
429
+ // hook and no module state to drift from it. Written ONLY under the verbose gate
430
+ // — data-reactive-verbose on <html> or on any reactive root (stamped in dev/test
431
+ // by default) — so production writes nothing. The gate is checked once per
432
+ // network request, never per event.
433
+ export const REQUESTS_ATTR = "data-reactive-requests"
434
+
435
+ export function countReactiveRequest(kind) {
436
+ if (typeof document === "undefined") return
437
+ const root = document.documentElement
438
+ if (typeof root?.setAttribute !== "function" || !reactiveRequestsCounted(root)) return
439
+ const totals = readReactiveRequests(root)
440
+ totals[kind] = (Number(totals[kind]) || 0) + 1
441
+ root.setAttribute(REQUESTS_ATTR, JSON.stringify(totals))
442
+ }
443
+
444
+ function reactiveRequestsCounted(root) {
445
+ return root.hasAttribute?.("data-reactive-verbose") || !!document.querySelector?.('[data-reactive-verbose="true"]')
446
+ }
447
+
448
+ // The current totals, or zeros when the attribute is absent or not an object
449
+ // (a hand-edited / garbled value restarts the count rather than throwing).
450
+ function readReactiveRequests(root) {
451
+ try {
452
+ const parsed = JSON.parse(root.getAttribute(REQUESTS_ATTR))
453
+ if (parsed && typeof parsed === "object") return { action: 0, defer: 0, ...parsed }
454
+ } catch {}
455
+ return { action: 0, defer: 0 }
456
+ }
457
+
458
+ export function registerReactiveActions() {
459
+ registerReactiveVisit()
460
+ registerReactiveToken()
461
+ registerReactiveJs()
462
+ registerReactiveDefer()
463
+ registerReactiveStreamRender()
464
+ registerReactiveMorphFocus()
465
+ registerReactiveOffline()
466
+ registerReactiveDev()
467
+ }
468
+
469
+ // Escape a DOM id for safe interpolation into a RegExp (an id can legally contain
470
+ // regex metacharacters like `.`/`:` — e.g. an `escape:`-namespaced or dotted id).
471
+ // Used by #extractToken to match the stream that re-renders THIS element by id.
472
+ export function escapeRegExp(string) {
473
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
474
+ }
475
+
476
+ // --- Loaded-twice guard (issue #275) ------------------------------------------
477
+ // Each shipped entry carries its own copy of this file. An app that imports
478
+ // BOTH phlex/reactive/reactive_controller and phlex/reactive/core (or bundles
479
+ // one and pins the other) gets two runtimes: two request queues' worth of
480
+ // module state, two activity counters, two feature tables. Say so, loudly.
481
+ const CORE_LOADED = Symbol.for("phlex-reactive.core")
482
+ if (globalThis[CORE_LOADED]) {
483
+ console.error(
484
+ "[phlex-reactive] the client was loaded twice. Import EITHER phlex/reactive/reactive_controller (the default, " +
485
+ "everything in one file) OR phlex/reactive/core (features on demand), not both — each carries its own state.",
486
+ )
487
+ }
488
+ globalThis[CORE_LOADED] = true
489
+
490
+ // --- Registration guard (issue #26 part 2) -------------------------------
491
+ // In a `lazyLoadControllersFrom("controllers", application)` app, only
492
+ // controllers under app/javascript/controllers/ are registered. This module
493
+ // lives outside that dir, so importing it isn't enough — `data-controller=
494
+ // "reactive"` does NOTHING until the host runs application.register("reactive",
495
+ // ...). The failure is silent: components render, but no action ever fires.
496
+ //
497
+ // We can't warn from connect() in that case (connect never runs). Instead, once
498
+ // the page is ready, if reactive elements exist but no controller has connected,
499
+ // the controller wasn't registered — so we warn, pointing at the fix.
500
+ let reactiveConnected = false
501
+
502
+ export function checkReactiveRegistration() {
503
+ if (reactiveConnected) return
504
+ if (typeof document === "undefined") return
505
+ const els = document.querySelectorAll('[data-controller~="reactive"]')
506
+ if (!els || els.length === 0) return
507
+ console.warn(
508
+ "[phlex-reactive] found " + els.length + ' element(s) with data-controller="reactive" ' +
509
+ "but the reactive controller never connected. It is loaded but not registered — " +
510
+ 'add `application.register("reactive", ReactiveController)` (importmap) or import it ' +
511
+ "into app/javascript/controllers/ for lazyLoadControllersFrom apps. See the README."
512
+ )
513
+ }
514
+
515
+ // Test seams (no-ops in production usage).
516
+ export function __resetReactiveRegistrationForTest() {
517
+ reactiveConnected = false
518
+ }
519
+ export function __markReactiveConnectedForTest() {
520
+ reactiveConnected = true
521
+ }
522
+
523
+ if (typeof window !== "undefined" && typeof document !== "undefined") {
524
+ // Defer past initial controller connection (a microtask/tick after ready).
525
+ const scheduleCheck = () => setTimeout(checkReactiveRegistration, 0)
526
+ if (document.readyState === "loading") {
527
+ document.addEventListener("DOMContentLoaded", scheduleCheck, { once: true })
528
+ } else {
529
+ scheduleCheck()
530
+ }
531
+ }
532
+
533
+ // The interpret-time attribute-name allowlist (issue #96) — the SECOND half of
534
+ // the two-sided default-deny. The Ruby builder already refuses these at build
535
+ // time; this guards a hand-built / forged ops attr from bypassing it. Refused:
536
+ // event handlers (on*, XSS), URL-bearing names (a javascript: navigation
537
+ // surface), and style (CSS injection). Case-insensitive, mirroring js.rb.
538
+ const REFUSED_ATTR_URL = new Set(["href", "src", "srcdoc", "action", "formaction", "xlink:href", "style"])
539
+ function attrRefused(name) {
540
+ const lower = String(name).toLowerCase()
541
+ return lower.startsWith("on") || REFUSED_ATTR_URL.has(lower)
542
+ }
543
+
544
+ // Run an animated visibility change (issue #96 `transition:`). `flip` performs
545
+ // the actual hidden-flag change; `[during, from, to]` are class lists applied
546
+ // AROUND it. Cleanup is awaited via the element's OWN animationend/transitionend
547
+ // OR a setTimeout fallback — whichever comes first — so an element with NO
548
+ // animation never leaves the helper classes stuck (the op chain itself is not
549
+ // blocked: cleanup is fire-and-forget, later ops run immediately). Both end
550
+ // events bubble, so a descendant's is ignored, and settling drops both
551
+ // listeners (the effects.js fix, #296). The fallback is armed synchronously,
552
+ // not behind the frame, so a hidden tab (no rAF) still cleans up; cleanup then
553
+ // cancels the pending frame and clears `from` too, so a late frame can't leave
554
+ // `to` (or `from`) stuck. A one-shot `done` guard runs cleanup exactly once.
555
+ // One run per element: a new run first settles the live one (its classes,
556
+ // timer, listeners and frame), so a superseded run's late wakeups can't strip
557
+ // the new run's classes. The fallback follows the element's computed
558
+ // durations (transitionFallbackMs), so a 600 ms transition isn't cut at 350.
559
+ const transitionRuns = new WeakMap()
560
+ function runTransition(el, transition, flip) {
561
+ const [during, from, to] = transition
562
+ transitionRuns.get(el)?.()
563
+ el.classList.add(during, from)
564
+ flip()
565
+ let done = false
566
+ const frame = requestAnimationFrame(() => {
567
+ if (done) return
568
+ el.classList.remove(from)
569
+ el.classList.add(to)
570
+ })
571
+
572
+ const cleanup = (event) => {
573
+ if (done || (event && event.target !== el)) return
574
+ done = true
575
+ transitionRuns.delete(el)
576
+ clearTimeout(timer)
577
+ globalThis.cancelAnimationFrame?.(frame)
578
+ el.removeEventListener("animationend", cleanup)
579
+ el.removeEventListener("transitionend", cleanup)
580
+ el.classList.remove(during, from, to)
581
+ }
582
+ transitionRuns.set(el, cleanup)
583
+ el.addEventListener("animationend", cleanup)
584
+ el.addEventListener("transitionend", cleanup)
585
+ // Also the ONLY path for a non-animated element (no end event fires there),
586
+ // so it must always be scheduled — and never behind the frame.
587
+ const timer = setTimeout(cleanup, transitionFallbackMs(el))
588
+ }
589
+
590
+ // The longest computed duration × iteration-count + delay over the transition
591
+ // and animation lists (CSS repeats the shorter list; a transition runs once,
592
+ // `infinite` hits the cap), plus 50 ms; 350 ms when nothing is declared (or
593
+ // there is no computed style), capped at 5 s so a bogus value can't wedge the
594
+ // classes on. Read once `during` is on the element.
595
+ function transitionFallbackMs(el) {
596
+ let longest = 0
597
+ try {
598
+ const style = getComputedStyle(el)
599
+ for (const kind of ["transition", "animation"]) {
600
+ const [durations, delays] = ["Duration", "Delay"].map((name) =>
601
+ String(style[kind + name])
602
+ .split(",")
603
+ .map((v) => parseFloat(v) * (/ms/.test(v) ? 1 : 1000) || 0),
604
+ )
605
+ const counts = String(style[kind + "IterationCount"])
606
+ .split(",")
607
+ .map((v) => (/inf/.test(v) ? Infinity : parseFloat(v) >= 0 ? parseFloat(v) : 1))
608
+ for (let i = 0; i < durations.length || i < delays.length || i < counts.length; i++) {
609
+ const runs = durations[i % durations.length] * counts[i % counts.length] || 0
610
+ longest = Math.max(longest, runs + delays[i % delays.length])
611
+ }
612
+ }
613
+ } catch {}
614
+ return Math.min(Math.max(longest + 50, 350), 5000)
615
+ }
616
+
617
+ // Rich-text and contenteditable fields, as #collectFields reads them (minus
618
+ // the [name] guard — an editor's name may live on its IDL `name` getter:
619
+ // Trix's `input=`-paired hidden input). The persist feature keeps its own copy
620
+ // of these two constants (features never import the core); a test pins that
621
+ // they stay equal.
622
+ export const EDITOR_SELECTOR =
623
+ ":is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])"
624
+ export const EDITOR_TAGS = new Set(["lexxy-editor", "trix-editor"])
625
+
626
+ // A RICH editor (lexxy/trix) is only ready once its custom element upgraded —
627
+ // Trix defines its elements in a setTimeout after load — and exposes the
628
+ // string `value` accessor; reading it before that yields "", which is issue
629
+ // #8. A bare [contenteditable] is plain DOM and always ready.
630
+ function collectorEditorReady(el) {
631
+ return EDITOR_TAGS.has(el.localName) ? typeof el.value === "string" : true
632
+ }
633
+
634
+ // The client-op whitelist behind on_client (issue #95, extended in #96). Mirrors
635
+ // Phlex::Reactive::JS's vocabulary; an op name not in this map is
636
+ // warn-and-skipped by #applyOps (client-side default-deny — a stale or newer
637
+ // ops attr must never break the page). Each op is a pure, local DOM mutation:
638
+ // nothing is sent anywhere, and nothing is read back — with ONE deliberate
639
+ // exception, paste_into (issue #228), which reads the clipboard behind the
640
+ // browser's own gesture + permission gates and still only writes locally.
641
+ // Frozen so nothing can be registered into it at runtime — extending the
642
+ // vocabulary is a gem change, not an app hook.
643
+ const CLIENT_OPS = Object.freeze({
644
+ show: (el, args, resolveTargets) => setVisibility(el, false, args, resolveTargets),
645
+ hide: (el, args, resolveTargets) => setVisibility(el, true, args, resolveTargets),
646
+ toggle: (el, args, resolveTargets) => setVisibility(el, !el.hidden, args, resolveTargets),
647
+ add_class: (el, args) => el.classList.add(...(args.classes ?? [])),
648
+ remove_class: (el, args) => el.classList.remove(...(args.classes ?? [])),
649
+ toggle_class: (el, args) => (args.classes ?? []).forEach((c) => el.classList.toggle(c)),
650
+
651
+ // Attribute ops (issue #96), interpret-time allowlisted. set_attr writes the
652
+ // (already-stringified) value; toggle_attr adds a missing attr (value "") or
653
+ // removes a present one — or, given values [on, off] (issue #271), flips
654
+ // between them (an absent attr becomes `on`); remove_attr removes it. A
655
+ // refused name warns + skips.
656
+ set_attr: (el, args) => {
657
+ if (guardAttr(args.name)) el.setAttribute(args.name, args.value ?? "")
658
+ },
659
+ remove_attr: (el, args) => {
660
+ if (guardAttr(args.name)) el.removeAttribute(args.name)
661
+ },
662
+ toggle_attr: (el, args) => {
663
+ if (!guardAttr(args.name)) return
664
+ if (Array.isArray(args.values)) {
665
+ const [on, off] = args.values
666
+ el.setAttribute(args.name, el.getAttribute(args.name) === on ? off : on)
667
+ } else if (el.hasAttribute(args.name)) el.removeAttribute(args.name)
668
+ else el.setAttribute(args.name, "")
669
+ },
670
+
671
+ // Focus ops (issue #96). focus targets the match itself; focus_first targets
672
+ // its first focusable descendant (opened-menu → first menuitem).
673
+ focus: (el) => el.focus?.(),
674
+ focus_first: (el) => firstFocusable(el)?.focus?.(),
675
+
676
+ // Text op (issue #159): set textContent — XSS-safe by construction (never
677
+ // innerHTML), strictly less powerful than set_attr. Change-guarded like
678
+ // #mirrorText. With global: true it is the cross-root text escape: paint a
679
+ // value into a recap node OUTSIDE the component's root.
680
+ text: (el, args) => {
681
+ const text = String(args.value ?? "")
682
+ if (el.textContent !== text) el.textContent = text
683
+ },
684
+
685
+ // Dispatch a bubbling CustomEvent (issue #96). RAW element.dispatchEvent — the
686
+ // controller SHADOWS Stimulus's this.dispatch helper, so it must not be used.
687
+ dispatch: (el, args) => {
688
+ el.dispatchEvent(new CustomEvent(args.name, { bubbles: true, composed: true, detail: args.detail ?? {} }))
689
+ },
690
+
691
+ // Submit the target's OWN form (issue #226) via requestSubmit() — constraint
692
+ // validation runs and a REAL cancelable `submit` event fires, so an
693
+ // on(:action, event: "submit") interception or a native/Turbo form handles it
694
+ // exactly like a user submit. No form → no-op. ACTOR-ONLY like focus: the
695
+ // broadcast builder refuses it server-side (BROADCAST_REFUSED_OPS).
696
+ // `submitter:` (issue #319) resolves with the op's own scoping (the
697
+ // `expanded:` precedent) and submits THROUGH it, so its name=value posts. It
698
+ // must be a submit control of that form — requestSubmit(x) throws otherwise —
699
+ // so anything else warns and falls back to a plain requestSubmit().
700
+ submit: (el, args, resolveTargets) => {
701
+ const form = submitFormFor(el)
702
+ if (!form) return
703
+ let submitter = null
704
+ if (args?.submitter != null && typeof resolveTargets === "function") {
705
+ submitter = resolveTargets({ ...args, to: args.submitter })[0] ?? null
706
+ if (!(submitter?.form === form && (submitter.type === "submit" || submitter.type === "image"))) {
707
+ console.warn(`[phlex-reactive] submitter ${args.submitter} is not a submit control of the form — ignored`)
708
+ submitter = null
709
+ }
710
+ }
711
+ submitter ? form.requestSubmit?.(submitter) : form.requestSubmit?.()
712
+ },
713
+
714
+ // Clipboard-source paste (issue #228): on a user gesture, read
715
+ // navigator.clipboard.readText() and feed the text into the target field
716
+ // through the normal input pipeline — exactly what a native Cmd/Ctrl+V does.
717
+ // The ONE op that reads a browser API and is async: fire-and-forget, so
718
+ // applyOps stays sync and chain siblings never wait (the runTransition
719
+ // posture). A rejected/dismissed read, empty text, or a missing API is a
720
+ // SILENT no-op — page state must not change. ACTOR-ONLY like focus/submit:
721
+ // the broadcast builder refuses it server-side (BROADCAST_REFUSED_OPS) —
722
+ // and that server gate is the REAL one. The browser only partially backs it
723
+ // up: Safari gates every read on a fresh gesture and Firefox shows its
724
+ // paste picker per read, but Chromium's clipboard-read is a PERSISTENT
725
+ // per-origin permission — once granted (the legit paste button itself
726
+ // induces that), readText() succeeds with no gesture. No client-side
727
+ // refusal is possible here: the reactive:js interpreter cannot distinguish
728
+ // an actor reply's stream from a broadcast's, and reply.js legitimately
729
+ // carries this op.
730
+ paste_into: (el) => pasteClipboardInto(el),
731
+
732
+ // Client-only drafts (issue #239): merge a flat state bag into the root's
733
+ // reactive_persist draft / forget the draft. ACTOR-ONLY like focus/submit
734
+ // (BROADCAST_REFUSED_OPS server-side) — rewriting or wiping every
735
+ // subscriber's draft from a broadcast would be hostile.
736
+ // The draft code lives in the persist feature module (issue #275), so both
737
+ // ops run once it has loaded — at once on a root that restored a draft.
738
+ persist_state: (el, args) =>
739
+ __SPLIT__
740
+ ? withFeature("persist", (persist) => persist.writeState(el, args.state))
741
+ : persistFeature.writeState(el, args.state),
742
+ persist_clear: (el) =>
743
+ __SPLIT__ ? withFeature("persist", (persist) => persist.clearRoot(el)) : persistFeature.clearRoot(el),
744
+ })
745
+
746
+ // The form a submit op commits (issue #226), in order: the target itself when
747
+ // it IS a form (tagName, not instanceof — fake-node/test friendly), its form
748
+ // owner for a control (input.form — honors a form= attribute), else the nearest
749
+ // ancestor form. closest() may cross the component root by design — the
750
+ // FIELD'S OWN form is the submit scope, not the reactive boundary.
751
+ function submitFormFor(el) {
752
+ if (el?.tagName === "FORM") return el
753
+ return el?.form ?? el?.closest?.("form") ?? null
754
+ }
755
+
756
+ // Read the clipboard into a field (issue #228) — the body of the paste_into
757
+ // op. The write mirrors a native paste: set .value, dispatch a bubbling
758
+ // `input` event (the set-value + dispatch contract, issue #183 — compute
759
+ // reducers, show bindings, and on_complete all run exactly as if the user
760
+ // had typed), then focus (the caret lands where the user continues typing on
761
+ // a partial paste). Availability-guarded: insecure contexts and some
762
+ // webviews have no navigator.clipboard — the connect()-time gate hides
763
+ // marked triggers there, so this guard is belt-and-braces. Empty text is a
764
+ // no-op: "paste nothing" must not clear a half-typed field.
765
+ function pasteClipboardInto(field) {
766
+ const clipboard = globalThis.navigator?.clipboard
767
+ if (typeof clipboard?.readText !== "function") return
768
+ clipboard
769
+ .readText()
770
+ .then((text) => {
771
+ if (!text) return
772
+ field.value = text
773
+ if (typeof field.dispatchEvent === "function") field.dispatchEvent(new Event("input", { bubbles: true }))
774
+ field.focus?.()
775
+ })
776
+ .catch(() => {
777
+ // Permission denied or the prompt dismissed — the browser's own UX said
778
+ // no. The issue-#228 contract: a silent no-op, never an error.
779
+ })
780
+ }
781
+
782
+ // Apply a hidden-flag change, optionally animated by a [during, from, to]
783
+ // transition (issue #96). Split out so show/hide/toggle share it.
784
+ function setHidden(el, hidden, args) {
785
+ if (args?.transition) runTransition(el, args.transition, () => (el.hidden = hidden))
786
+ else el.hidden = hidden
787
+ }
788
+
789
+ // show/hide/toggle (issue #271): flip visibility, then mirror the INTENDED
790
+ // state into the `expanded:` target's aria-expanded — computed before the flip,
791
+ // so a transition never delays or races it. The expanded target resolves with
792
+ // the op's own scoping (root-scoped, or document-wide under global: true).
793
+ function setVisibility(el, hidden, args, resolveTargets) {
794
+ setHidden(el, hidden, args)
795
+ if (args?.expanded == null || typeof resolveTargets !== "function") return
796
+ for (const target of resolveTargets({ ...args, to: args.expanded })) {
797
+ target.setAttribute?.("aria-expanded", String(!hidden))
798
+ }
799
+ }
800
+
801
+ // The interpret-time attribute guard: refuse (warn + skip) a name off the
802
+ // allowlist. Returns true when the op may proceed.
803
+ function guardAttr(name) {
804
+ if (!attrRefused(name)) return true
805
+ console.warn(`[phlex-reactive] refused client attr op on ${JSON.stringify(name)} — skipped`)
806
+ return false
807
+ }
808
+
809
+ // The first focusable descendant of `el`, in document order — the natural
810
+ // keyboard target inside an opened menu/dialog. Covers the standard focusable
811
+ // set; :not([tabindex="-1"]) drops explicitly-removed nodes. Returns null when
812
+ // nothing inside is focusable (focus_first then no-ops).
813
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
814
+ function firstFocusable(el) {
815
+ return el.querySelectorAll?.(FOCUSABLE)?.[0] ?? null
816
+ }
817
+
818
+ // Parse a [[name, args], ...] op list from a raw attr/param. An array passes
819
+ // through; a JSON string is parsed; anything malformed degrades to [] — a bad
820
+ // ops attr must NEVER break the page (client-side default-deny). Shared by the
821
+ // controller's runOps and the reactive:js stream action (issue #97).
822
+ function parseOps(raw) {
823
+ if (Array.isArray(raw)) return raw
824
+ if (typeof raw !== "string") return []
825
+ try {
826
+ const list = JSON.parse(raw)
827
+ return Array.isArray(list) ? list : []
828
+ } catch {
829
+ return []
830
+ }
831
+ }
832
+
833
+ // --- on_client binding records (issue #271) -----------------------------------
834
+ // Each on_client call emits ONE record ({on, ops, window?, outside?, confirm?,
835
+ // confirmWhen?}); mix space-joins several onto one element (spaces inside a
836
+ // record ride as \u0020, so the join is unambiguous). Stimulus hands every
837
+ // runOps descriptor on an element the SAME event.params, so runOps selects the
838
+ // record(s) whose descriptor matches the firing event: event.type, the key
839
+ // filter, and window-boundness.
840
+
841
+ // Stimulus's default keyMappings (letters and digits map to themselves). A
842
+ // custom Stimulus schema is not mirrored — an unknown filter never matches.
843
+ const KEY_FILTER_MAP = Object.freeze({
844
+ enter: "Enter",
845
+ tab: "Tab",
846
+ esc: "Escape",
847
+ space: " ",
848
+ up: "ArrowUp",
849
+ down: "ArrowDown",
850
+ left: "ArrowLeft",
851
+ right: "ArrowRight",
852
+ home: "Home",
853
+ end: "End",
854
+ page_up: "PageUp",
855
+ page_down: "PageDown",
856
+ })
857
+ const KEY_FILTER_MODIFIERS = Object.freeze(["meta", "ctrl", "alt", "shift"])
858
+
859
+ // Stimulus's rule: the four modifiers must match EXACTLY (ctrl+k is not k);
860
+ // the remaining token is compared to event.key case-insensitively. A
861
+ // non-keyboard event (no event.key) checks the modifiers only. `keyMappings`
862
+ // is the app's Stimulus schema table (custom keys included) when the
863
+ // controller has one; the default table is the fallback.
864
+ function keyFilterMatches(filter, event, keyMappings) {
865
+ const parts = filter.split("+")
866
+ for (const mod of KEY_FILTER_MODIFIERS) {
867
+ if (parts.includes(mod) !== Boolean(event[`${mod}Key`])) return false
868
+ }
869
+ const token = parts.find((part) => !KEY_FILTER_MODIFIERS.includes(part))
870
+ if (token === undefined || typeof event.key !== "string") return true
871
+ const key =
872
+ keyMappings && Object.hasOwn(keyMappings, token)
873
+ ? keyMappings[token]
874
+ : Object.hasOwn(KEY_FILTER_MAP, token)
875
+ ? KEY_FILTER_MAP[token]
876
+ : /^[a-z0-9]$/.test(token)
877
+ ? token
878
+ : null
879
+ return typeof key === "string" && key.toLowerCase() === event.key.toLowerCase()
880
+ }
881
+
882
+ // Parse data-reactive-ops-param into binding records. Stimulus typecasts a
883
+ // lone record to an object; several records stay a string (JSON.parse fails on
884
+ // the join) and are split here. A LEGACY [[op, args]] list (array or JSON
885
+ // string — a hand-built attr) becomes one record flagged `legacy`: it has no
886
+ // `on`, so it always matches, and runOps reads its flags from event.params. A
887
+ // malformed piece warns and is skipped; its siblings still run (default-deny).
888
+ function parseBindingRecords(raw) {
889
+ if (Array.isArray(raw)) return [{ ops: raw, legacy: true }]
890
+ if (raw && typeof raw === "object") return [raw]
891
+ if (typeof raw !== "string") return []
892
+ const text = raw.trim()
893
+ if (text.startsWith("[")) return [{ ops: parseOps(text), legacy: true }]
894
+ const records = []
895
+ for (const piece of text.split(" ")) {
896
+ if (piece === "") continue
897
+ let record = null
898
+ try {
899
+ record = JSON.parse(piece)
900
+ } catch {
901
+ record = null
902
+ }
903
+ if (record && typeof record === "object" && !Array.isArray(record)) records.push(record)
904
+ else console.warn(`[phlex-reactive] malformed on_client binding record ${JSON.stringify(piece)} — skipped`)
905
+ }
906
+ return records
907
+ }
908
+
909
+ // Does this record's descriptor match the firing event? The window check
910
+ // matters for an element carrying both `click` and `click@window`: one inside
911
+ // click reaches both listeners, and only currentTarget tells them apart. It is
912
+ // recognised structurally (isWindow), so an undefined currentTarget is never
913
+ // window-bound and a replay built for another document's window still counts
914
+ // (issue #303). Only a LEGACY record (no `on` by
915
+ // construction) matches everything; any other record without a descriptor is
916
+ // malformed and never matches (default-deny). `keyMappings === null` skips the
917
+ // key filter (the caller's single-candidate shortcut).
918
+ function bindingMatches(record, event, keyMappings) {
919
+ if (record.legacy) return true
920
+ const on = record.on
921
+ if (typeof on !== "string" || on === "") return false
922
+ const dot = on.indexOf(".")
923
+ if (event.type !== (dot < 0 ? on : on.slice(0, dot))) return false
924
+ if (Boolean(record.window) !== isWindow(event.currentTarget)) return false
925
+ return dot < 0 || keyMappings === null || keyFilterMatches(on.slice(dot + 1), event, keyMappings)
926
+ }
927
+
928
+ // A `once: true` record (PR #272) is spent after its first run. Stimulus
929
+ // removes only that binding's own listener; a regular same-event sibling on
930
+ // the element keeps calling runOps with BOTH records, so the spent one is
931
+ // skipped here. Spent state belongs to the TRIGGER element (a WeakMap, so it
932
+ // is collected with it): a morph/stream that swaps in a fresh element starts
933
+ // fresh, and a byte-identical sibling trigger has its own. A window-bound
934
+ // binding's currentTarget is the window, so there the element's ops attr joins
935
+ // the key (a re-rendered window-bound once trigger with the same markup stays
936
+ // spent — a known limit; spentEarlyOnce below has the same one for a replayed
937
+ // `:once` hotkey).
938
+ const spentOnceBindings = new WeakMap()
939
+ function onceBindingSpent(controller, event, record) {
940
+ if (!record.once) return false
941
+ const owner = event.currentTarget ?? controller.element
942
+ let byOwner = spentOnceBindings.get(controller)
943
+ if (!byOwner) {
944
+ byOwner = new WeakMap()
945
+ spentOnceBindings.set(controller, byOwner)
946
+ }
947
+ let spent = byOwner.get(owner)
948
+ if (!spent) {
949
+ spent = new Set()
950
+ byOwner.set(owner, spent)
951
+ }
952
+ const raw = event.params?.ops
953
+ const attr = owner === globalThis.window ? `${typeof raw === "string" ? raw : JSON.stringify(raw ?? null)}|` : ""
954
+ const key = attr + JSON.stringify(record)
955
+ if (spent.has(key)) return true
956
+ spent.add(key)
957
+ return false
958
+ }
959
+
960
+ // Two identical descriptors on one element (two mix-ed on_client calls with
961
+ // the same event) are two Stimulus bindings, so runOps runs twice for ONE
962
+ // event. The first call runs every matching record; a repeat is a no-op.
963
+ // "Repeat" is (controller, listener target, the element's ops attr): Stimulus
964
+ // walks EVERY binding of one window listener with the SAME event object, so
965
+ // keying on currentTarget alone (= window) would drop every other root's
966
+ // window-bound binding — and a sibling element's within one root. The ops attr
967
+ // is the per-element key (a lone record is typecast to a fresh object per
968
+ // binding, so it is compared by its JSON). Two elements in one root carrying
969
+ // byte-identical window-bound records run once — the same ops, applied once.
970
+ const ranBindings = new WeakMap()
971
+ function bindingsAlreadyRan(event, controller) {
972
+ if (event === null || typeof event !== "object") return false
973
+ const raw = event.params?.ops
974
+ const key = typeof raw === "string" ? raw : JSON.stringify(raw ?? null)
975
+ let byController = ranBindings.get(event)
976
+ if (!byController) {
977
+ byController = new WeakMap()
978
+ ranBindings.set(event, byController)
979
+ }
980
+ let byTarget = byController.get(controller)
981
+ if (!byTarget) {
982
+ byTarget = new Map()
983
+ byController.set(controller, byTarget)
984
+ }
985
+ let keys = byTarget.get(event.currentTarget)
986
+ if (!keys) {
987
+ keys = new Set()
988
+ byTarget.set(event.currentTarget, keys)
989
+ }
990
+ if (keys.has(key)) return true
991
+ keys.add(key)
992
+ return false
993
+ }
994
+
995
+ // Interpret a [[name, args], ...] op list against the frozen CLIENT_OPS
996
+ // whitelist (issues #95/#96/#97). `resolveTargets(args)` returns the element(s)
997
+ // an op applies to — the controller scopes to its root (excluding nested
998
+ // reactive roots); the reactive:js stream action scopes to its target root (or
999
+ // the document). An unknown name warns and is SKIPPED while the rest of the
1000
+ // chain still applies — client-side default-deny, one bad op never takes down
1001
+ // its siblings. Object.hasOwn (not a bare read) so inherited Object members
1002
+ // ("constructor") can't masquerade as ops.
1003
+ function applyOps(list, resolveTargets, onZeroTargets) {
1004
+ for (const entry of list) {
1005
+ if (!Array.isArray(entry)) continue
1006
+ const [name, args = {}] = entry
1007
+ if (!Object.hasOwn(CLIENT_OPS, name)) {
1008
+ console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(name)} — skipped`)
1009
+ continue
1010
+ }
1011
+ const targets = resolveTargets(args)
1012
+ if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
1013
+ for (const el of targets) CLIENT_OPS[name](el, args, resolveTargets)
1014
+ }
1015
+ }
1016
+
1017
+ // Resolve a reactive:js op's targets against its `target` root (issue #97).
1018
+ // "@root" is the root element itself; a selector resolves WITHIN it; a bare
1019
+ // selector with no root (no `target` attr on the stream) resolves document-wide
1020
+ // — a broadcast op anchored by a global selector (#bell) rather than a
1021
+ // component. Unlike the controller path there is no nested-reactive-root
1022
+ // ownership filter: a server-pushed op names its own scope explicitly —
1023
+ // including `global: true`, which opts a single op out of the target-root
1024
+ // scope to document-wide resolution (issue #159; the same escape the builder
1025
+ // documents for the controller path).
1026
+ function streamOpTargets(args, root) {
1027
+ const to = args.to
1028
+ if (root) {
1029
+ if (to === "@root") return [root]
1030
+ if (typeof to !== "string" || to === "") return []
1031
+ if (args.global) return [...document.querySelectorAll(to)]
1032
+ return [...root.querySelectorAll(to)]
1033
+ }
1034
+ // No target root: document-scoped. "@root" is meaningless here (nothing to
1035
+ // anchor to) → no-op; a selector matches document-wide.
1036
+ if (typeof to !== "string" || to === "" || to === "@root") return []
1037
+ return [...document.querySelectorAll(to)]
1038
+ }
1039
+
1040
+ // --- Feature modules (issue #275) ---------------------------------------------
1041
+ // The client is a small core plus feature modules a page loads only when one
1042
+ // of its roots asks for them. Each entry is [needs, load, waiting?, gates?]:
1043
+ //
1044
+ // needs(root) a cheap marker read — does this root use the feature?
1045
+ // null for a feature no root asks for (it is loaded by
1046
+ // something page-level instead).
1047
+ // load() how to import the module — NOT written here. core.js,
1048
+ // the opt-in entry, supplies one per feature
1049
+ // (registerReactiveFeatureLoader): a LITERAL
1050
+ // import("phlex/reactive/features/<name>"), so a bundler can
1051
+ // see the specifier and the import map resolves it to its
1052
+ // own digested file. The default entry supplies none: its
1053
+ // features are part of the file.
1054
+ // waiting(root, pending)
1055
+ // optional, and like load() supplied by core.js (only the
1056
+ // opt-in client ever waits): what must not be missed WHILE
1057
+ // the import is on its way. Runs when a root starts waiting, RECORDS into
1058
+ // `pending` (a plain object the feature receives later) and
1059
+ // returns its own undo. Core bytes — record, never act.
1060
+ // gates optional, true when the feature's connect changes what a
1061
+ // REQUEST reads (the draft restore writes the fields a
1062
+ // request collects). A root's action requests wait for its
1063
+ // features only while one that gates is still loading; a
1064
+ // feature that does not gate never delays a request.
1065
+ //
1066
+ // TABLE ORDER IS CONNECT ORDER (and disconnect order): persist is first
1067
+ // because its restore writes the values every other connect-time seed reads.
1068
+ //
1069
+ // A feature module exports
1070
+ //
1071
+ // install(shared) optional; once, when it has loaded
1072
+ // connect(controller, core, morphed, pending)
1073
+ // once per root connection; `pending` is what
1074
+ // its `waiting` hook recorded (undefined when
1075
+ // the root never had to wait)
1076
+ // disconnect(controller, core)
1077
+ // FIRST in the controller's disconnect()
1078
+ // abandon(root, pending) optional; the root left BEFORE the feature
1079
+ // could connect — last chance to act on
1080
+ // what was recorded
1081
+ //
1082
+ // and never imports this file (two copies of the core would split its state).
1083
+ // `shared` is FEATURE_SHARED below: the core's single-instance counters.
1084
+ // Everything a feature needs from one controller arrives as `core`, the
1085
+ // handle #featureCore builds — the ONE door into its private state:
1086
+ //
1087
+ // core.emit(name, detail, options) raw-dispatch a lifecycle event
1088
+ // core.reseed() re-run the connect-time seeds that
1089
+ // read field values
1090
+ // core.proceed(target, action, params) send an action down the ordinary
1091
+ // pipeline (veto, queue, request)
1092
+ // core.forgetToken() drop the token cached from a reply
1093
+ // core.owns(el) is this control this root's own, not
1094
+ // a nested reactive root's (issue #15)
1095
+ // core.ownership() the hoisted per-pass form of owns
1096
+ // core.opTargets(args) the elements an op's `to:` names
1097
+ // core.applyOps(list, defaultTo) run an op list root-scoped
1098
+ // core.diagnose(label, args) the zero-target warning
1099
+ // core.listnavOptions(event) the keyboard-navigable options
1100
+ // core.collectFields() this root's fields, as a request
1101
+ // would send them
1102
+ // core.confirm(message, context) the confirm gate (resolves a boolean)
1103
+ //
1104
+ // Later features add to that handle; nothing else of the controller is theirs
1105
+ // to touch beyond its public surface (element, application, …).
1106
+ //
1107
+ // WHEN A FEATURE CONNECTS. A module that is already here connects inside
1108
+ // connect() itself, in the same task, before the early-queue drain. In the
1109
+ // default bundle (phlex/reactive/reactive_controller) that is every feature,
1110
+ // always: they are handed over before anything connects, and nothing below
1111
+ // about imports, waiting, gates or timeouts ever applies.
1112
+ //
1113
+ // With phlex/reactive/core alone, the first root on a page that needs a
1114
+ // feature waits for its import: connect() only starts it, and the feature
1115
+ // connects when the module has arrived — after every feature EARLIER in the
1116
+ // table that this root is also waiting for (table order), never after a later
1117
+ // one: a slow defer import does not hold the draft restore back. Once the
1118
+ // module is loaded (a later root, the next Turbo visit, a reconnect) it too
1119
+ // connects inside connect().
1120
+ //
1121
+ // THE DEFAULT ENTRY HAS NONE OF THIS (issue #305). It is built with
1122
+ // `__SPLIT__` false: the runtime imports every feature statically and calls
1123
+ // it directly — connect() connects what the root's markers ask for, in this
1124
+ // order, by name in the source and by nothing at all in the minified file.
1125
+ // No table, no loader, no import(): what is below is the opt-in entry's.
1126
+
1127
+ // The marker checks of the features that connect per root. The default entry
1128
+ // calls them directly (#connectFeaturesNow); the table below hands them to
1129
+ // the loader.
1130
+ function persistNeeded(root) {
1131
+ const declared = root.getAttribute?.("data-reactive-persist")
1132
+ return Boolean(declared) && declared !== "off"
1133
+ }
1134
+
1135
+ // reply.defer, reactive_lazy, reactive_lazy(on:/cache:). A root is a lazy
1136
+ // shell when it carries any of these; real content carries none.
1137
+ function deferNeeded(root) {
1138
+ return ["defer-token", "defer-src", "lazy-on", "lazy-visible"].some(
1139
+ (marker) => root.getAttribute?.(`data-reactive-${marker}`) != null,
1140
+ )
1141
+ }
1142
+
1143
+ // Dirty tracking with its navigate-away guard, and the paste-trigger gate:
1144
+ // the root or anything inside it carries one of the two markers. (A nested
1145
+ // root's marker loads the module for its parent too; the module then checks
1146
+ // ownership before it wires anything.)
1147
+ function formNeeded(root) {
1148
+ return (
1149
+ (root.getAttribute?.("data-action") ?? "").includes("reactive#trackDirty") ||
1150
+ root.getAttribute?.("data-reactive-clipboard") != null ||
1151
+ root.querySelector?.('[data-action*="reactive#trackDirty"],[data-reactive-clipboard]') != null
1152
+ )
1153
+ }
1154
+
1155
+ // Show bindings and cross-root show targets, completion bindings, option
1156
+ // filtering, the bulk-selection group bindings (enable, select-all, count —
1157
+ // issue #319), the tag-chip input and draft nested rows: a form's client-only
1158
+ // bindings. The root declares one, or owns an element that does. (The probes
1159
+ // the connect-time gates ran before the split, unchanged.)
1160
+ function bindingsNeeded(root) {
1161
+ return (
1162
+ ["show-targets", "on-complete", "filter-input", "tags-field"].some(
1163
+ (marker) => root.getAttribute?.(`data-reactive-${marker}`) != null,
1164
+ ) ||
1165
+ (root.querySelectorAll?.("[data-reactive-show-field], [data-reactive-show]") ?? []).length > 0 ||
1166
+ root.querySelector?.(
1167
+ "[data-reactive-nested-json], [data-reactive-nested-list], [data-reactive-confirm-when-param], [data-reactive-enable], [data-reactive-select-all], [data-reactive-count]",
1168
+ ) != null
1169
+ )
1170
+ }
1171
+
1172
+ // Client-side computes: the root carries a reactive_compute binding.
1173
+ function computeNeeded(root) {
1174
+ return root.getAttribute?.("data-reactive-compute-inputs-param") != null
1175
+ }
1176
+
1177
+ const PRODUCTION_FEATURES = [
1178
+ // (Its `waiting` hook is the opt-in entry's: core.js.) The draft restore
1179
+ // writes what a request reads, so it gates.
1180
+ ["persist", [persistNeeded, null, null, true]],
1181
+ ["defer", [deferNeeded, null]],
1182
+ ["form", [formNeeded, null]],
1183
+ // Its seeds write what a request reads (the JSON-mode rows), so it gates.
1184
+ ["bindings", [bindingsNeeded, null, null, true]],
1185
+ // The seed writes output fields a request collects, so it gates.
1186
+ ["compute", [computeNeeded, null, null, true]],
1187
+ [
1188
+ // Stream effects and dismissing flashes are document-level: the module
1189
+ // has no connect. A root that DECLARES an effect only gets it fetched
1190
+ // early, so the first stream need not wait for it (see streamHold).
1191
+ "effects",
1192
+ [
1193
+ (root) => ["enter", "update", "exit"].some((hook) => root.getAttribute?.(`data-reactive-effect-${hook}`) != null),
1194
+ null,
1195
+ ],
1196
+ ],
1197
+ [
1198
+ // The optimistic/busy hint engine: a trigger in the root declares a hint.
1199
+ // No connect — the module acts at enqueue — so the marker is only read
1200
+ // while it is not loaded, and a root that declares one preloads it.
1201
+ "hints",
1202
+ [
1203
+ (root) =>
1204
+ root.querySelector?.(
1205
+ "[data-reactive-optimistic-param], [data-reactive-busy-param], [data-reactive-loading-param]",
1206
+ ) != null,
1207
+ null,
1208
+ ],
1209
+ ],
1210
+ [
1211
+ // Development aids: the latency simulator (page-level, see
1212
+ // registerReactiveDev), the zero-target diagnostics and the debug trace.
1213
+ // A root in debug mode preloads it; a verbose root asks for it the first
1214
+ // time it has something to warn about.
1215
+ "devtools",
1216
+ [(root) => root.getAttribute?.("data-reactive-debug") === "true", null],
1217
+ ],
1218
+ ]
1219
+ // (Only the opt-in entry has a table: `new Map` would keep it in the default.)
1220
+ const FEATURES = __SPLIT__ ? new Map(PRODUCTION_FEATURES) : null
1221
+
1222
+ // name -> the one import promise every root shares, kept even when it
1223
+ // rejected: a browser caches a module that failed to load, so importing it
1224
+ // again only re-rejects. A failed feature stays failed until the page is
1225
+ // reloaded; every root it costs is told (reactive:error).
1226
+ const featureLoads = __SPLIT__ ? new Map() : null
1227
+ // name -> the loaded module: what lets a later root connect a feature inside
1228
+ // connect(), and a stream action or client op use it in the same tick.
1229
+ const featureModules = __SPLIT__ ? new Map() : null
1230
+ // The modules the default bundle handed over (registerReactiveFeature); only
1231
+ // the test seam below reads it back.
1232
+ const featuresGiven = __SPLIT__ ? new Map() : null
1233
+
1234
+ // How the opt-in entry (core.js) tells the runtime where a feature module is
1235
+ // — `load`, a function returning its import() — and, optionally, what to
1236
+ // record while that import is on its way (`waiting`, see above).
1237
+ export function registerReactiveFeatureLoader(name, load, waiting) {
1238
+ if (__SPLIT__) {
1239
+ featureLoaders.set(name, [load, waiting])
1240
+ applyFeatureLoader(name)
1241
+ }
1242
+ }
1243
+
1244
+ // name -> [load, waiting] the opt-in entry registered: kept apart from the
1245
+ // table so a test's reset of the table puts them back.
1246
+ const featureLoaders = __SPLIT__ ? new Map() : null
1247
+
1248
+ function applyFeatureLoader(name) {
1249
+ const entry = FEATURES.get(name)
1250
+ const [load, waiting] = featureLoaders.get(name)
1251
+ entry[1] = load
1252
+ entry[2] = waiting
1253
+ }
1254
+
1255
+ // Hand the runtime a feature that is ALREADY loaded — what
1256
+ // phlex/reactive/reactive_controller does for every feature before anything
1257
+ // can connect. Such a feature is never imported: it connects inside connect().
1258
+ export function registerReactiveFeature(name, feature) {
1259
+ if (__SPLIT__) {
1260
+ feature.install?.(FEATURE_SHARED)
1261
+ featureModules.set(name, feature)
1262
+ featuresGiven.set(name, feature)
1263
+ }
1264
+ }
1265
+
1266
+ // For the opt-in entry: the features some of `elements` (reactive roots a
1267
+ // stream is about to render) need and that are not loaded — read off the
1268
+ // table, so a new feature is covered by its own marker check.
1269
+ export function unloadedFeaturesFor(elements) {
1270
+ const names = []
1271
+ if (__SPLIT__) {
1272
+ for (const [name, [needs]] of FEATURES) {
1273
+ if (!needs || featureModules.has(name)) continue
1274
+ try {
1275
+ if (elements.some((el) => needs(el))) names.push(name)
1276
+ } catch {
1277
+ // a marker check that throws is that root's problem at connect, not the stream's
1278
+ }
1279
+ }
1280
+ }
1281
+ return names
1282
+ }
1283
+
1284
+ // For the opt-in entry: import a feature now. Resolves with the module, or
1285
+ // with undefined once the failure has been logged.
1286
+ export function loadReactiveFeature(name) {
1287
+ return __SPLIT__
1288
+ ? loadFeature(name).catch((error) => logFeatureFailure(name, "load", error))
1289
+ : Promise.resolve()
1290
+ }
1291
+
1292
+ function loadFeature(name) {
1293
+ let loading = featureLoads.get(name)
1294
+ if (!loading) {
1295
+ const load = FEATURES.get(name)[1] ?? (() => Promise.reject(new Error("this entry has no way to import it")))
1296
+ loading = load().then((feature) => {
1297
+ feature.install?.(FEATURE_SHARED)
1298
+ featureModules.set(name, feature)
1299
+ return feature
1300
+ })
1301
+ featureLoads.set(name, loading)
1302
+ }
1303
+ return loading
1304
+ }
1305
+
1306
+ // Logged once per feature and phase per page (a failed import would otherwise
1307
+ // log again for every root that connects). `phase` says what went wrong:
1308
+ // "detect" (its marker check threw), "load" (the import failed — and stays
1309
+ // failed until the page is reloaded), "timeout" (slow; the root carried on
1310
+ // without it) or "connect" (it threw while wiring a root).
1311
+ const featureFailuresLogged = new Set()
1312
+
1313
+ function logFeatureFailure(name, phase, error) {
1314
+ const logged = name + phase
1315
+ if (featureFailuresLogged.has(logged)) return
1316
+ featureFailuresLogged.add(logged)
1317
+ console.error(
1318
+ `[phlex-reactive] the "${name}" feature module (phlex/reactive/features/${name}) is unavailable: ${phase}. ` +
1319
+ "If you bundle or vendor the client, alias or pin phlex/reactive/features/* (README: esbuild / webpack / bun).",
1320
+ error,
1321
+ )
1322
+ }
1323
+
1324
+ // Run `use(feature)` with a feature — for code with no root connection to
1325
+ // wait on (a client op, a stream action): in this tick when the module is
1326
+ // loaded, else once it has arrived. A feature that cannot load is logged.
1327
+ function withFeature(name, use) {
1328
+ const feature = featureModules.get(name)
1329
+ if (feature) return use(feature)
1330
+ loadFeature(name).then(use, (error) => logFeatureFailure(name, "load", error))
1331
+ }
1332
+
1333
+ // How long a root waits for a feature import before it carries on without it
1334
+ // (<meta name="phlex-reactive-feature-timeout" content="ms"> in <head>).
1335
+ // 10 s: the modules are a few KB, so even a slow 3G fetch (seconds, not tens
1336
+ // of seconds) finishes well inside it, and it matches the early-event TTL —
1337
+ // the other "how long may the client be late" window. A feature that arrives
1338
+ // AFTER the timeout still connects; the timeout only stops it holding back
1339
+ // the root's other features and its action requests.
1340
+ const DEFAULT_FEATURE_TIMEOUT_MS = 10000
1341
+
1342
+ function featureTimeoutMs() {
1343
+ const ms = Number(globalThis.document?.head?.querySelector?.('meta[name="phlex-reactive-feature-timeout"]')?.content)
1344
+ return Number.isFinite(ms) && ms > 0 ? ms : DEFAULT_FEATURE_TIMEOUT_MS
1345
+ }
1346
+
1347
+ export function reactiveFeatureNames() {
1348
+ return __SPLIT__
1349
+ ? [...FEATURES.keys()]
1350
+ : ["persist", "defer", "form", "bindings", "compute", "effects", "hints", "devtools"]
1351
+ }
1352
+
1353
+ export function __setReactiveFeatureForTest(name, needs, load, waiting, gates) {
1354
+ if (!__SPLIT__) return
1355
+ FEATURES.set(name, [needs, load, waiting, gates])
1356
+ }
1357
+
1358
+ // Test-only: a feature's table entry, to wrap one part of it.
1359
+ export function __reactiveFeatureEntryForTest(name) {
1360
+ return FEATURES.get(name)
1361
+ }
1362
+
1363
+ // Test-only: import a feature now (a later connect is then synchronous).
1364
+ export function __loadReactiveFeatureForTest(name) {
1365
+ if (__SPLIT__) return loadFeature(name)
1366
+ const modules = {
1367
+ persist: persistFeature,
1368
+ defer: deferFeature,
1369
+ form: formFeature,
1370
+ bindings: bindingsFeature,
1371
+ compute: computeFeature,
1372
+ effects: effectsFeature,
1373
+ hints: hintsFeature,
1374
+ devtools: devtoolsFeature,
1375
+ }
1376
+ return Promise.resolve(modules[name])
1377
+ }
1378
+
1379
+ // Test-only: back to the shipped table, with nothing logged and only the
1380
+ // modules the default bundle handed over still loaded — or, `cold`, with none
1381
+ // loaded at all: the state of a page that imported phlex/reactive/core.
1382
+ export function __resetReactiveFeaturesForTest(cold) {
1383
+ // (The default entry has no table to reset: its features are static.)
1384
+ if (!__SPLIT__) return
1385
+ FEATURES.clear()
1386
+ for (const [name, entry] of PRODUCTION_FEATURES) FEATURES.set(name, entry)
1387
+ // (Only the opt-in entry registers any; with the default entry alone the
1388
+ // table stays without a way to import, as shipped.)
1389
+ for (const name of featureLoaders.keys()) applyFeatureLoader(name)
1390
+ featureLoads.clear()
1391
+ featureModules.clear()
1392
+ if (!cold) for (const [name, feature] of featuresGiven) featureModules.set(name, feature)
1393
+ featureFailuresLogged.clear()
1394
+ }
1395
+
1396
+ const FEATURES_READY = Promise.resolve()
1397
+
1398
+ // The single-instance module state a feature may add to (install()): ONE
1399
+ // in-flight activity count and one request total per page, whoever counts.
1400
+ // `waiting` maps a root to { <feature name>: pending } while it waits for that
1401
+ // feature's import (see the `waiting` hook above).
1402
+ function forgetWaiting(root, name, pending) {
1403
+ const waiting = FEATURE_SHARED.waiting.get(root)
1404
+ // (A root that reconnected meanwhile has a NEW record under the same name.)
1405
+ if (waiting && waiting[name] === pending) delete waiting[name]
1406
+ }
1407
+
1408
+ const FEATURE_SHARED = {
1409
+ enter: enterReactiveActivity,
1410
+ exit: exitReactiveActivity,
1411
+ count: countReactiveRequest,
1412
+ waiting: new WeakMap(),
1413
+ }
1414
+
1415
+ // The default entry's per-root features, one bit each (#connectFeaturesNow).
1416
+ const PERSIST = 1
1417
+ const DEFER = 2
1418
+ const FORM = 4
1419
+ const BINDINGS = 8
1420
+ const COMPUTE = 16
1421
+
1422
+ // The default entry's features are part of this file: install them now, as
1423
+ // the opt-in entry does when one arrives.
1424
+ if (!__SPLIT__) {
1425
+ persistFeature.install(FEATURE_SHARED)
1426
+ deferFeature.install(FEATURE_SHARED)
1427
+ }
1428
+
1429
+ // --- Early triggers (issue #273) ----------------------------------------------
1430
+ // phlex/reactive/early (imported eagerly by the app) queues trigger events that
1431
+ // reach a root before its controller connects. The queue is shared through a
1432
+ // Symbol.for key on window — no import edge either way, so either module may
1433
+ // load first, and an app that never imports early gets an empty queue.
1434
+ const EARLY_KEY = Symbol.for("phlex-reactive.early")
1435
+ const DEFAULT_EARLY_TTL_MS = 10000
1436
+ // A window-bound trigger (a hotkey, issue #303) is replayed only this soon
1437
+ // after the keypress: later, the user has moved on. A shorter configured TTL
1438
+ // still wins.
1439
+ const WINDOW_EARLY_TTL_MS = 1500
1440
+
1441
+ // Is this event's currentTarget a Window (a Stimulus `@window` listener)? The
1442
+ // page's window, or any Window recognised structurally (another document's,
1443
+ // which a replay built from its trigger element's document carries).
1444
+ const isWindow = (target) => target != null && (target === globalThis.window || target.window === target)
1445
+
1446
+ function earlyState() {
1447
+ globalThis[EARLY_KEY] ??= { queue: [], connected: new WeakSet() }
1448
+ return globalThis[EARLY_KEY]
1449
+ }
1450
+
1451
+ // <meta name="phlex-reactive-early-ttl" content="ms"> (Phlex::Reactive.
1452
+ // early_event_ttl_ms), parsed defensively like the timeout meta.
1453
+ function earlyTtlMs() {
1454
+ const ms = Number(globalThis.document?.querySelector?.('meta[name="phlex-reactive-early-ttl"]')?.content)
1455
+ return Number.isFinite(ms) && ms > 0 ? ms : DEFAULT_EARLY_TTL_MS
1456
+ }
1457
+
1458
+ // Stimulus's param reader (data-reactive-<name>-param, JSON-typecast,
1459
+ // camelized) — a replayed trigger carries the same event.params a live one does.
1460
+ function stimulusParams(el) {
1461
+ const params = {}
1462
+ for (const { name, value } of Array.from(el.attributes ?? [])) {
1463
+ const match = /^data-reactive-(.+)-param$/i.exec(name)
1464
+ if (!match) continue
1465
+ let typed
1466
+ try {
1467
+ typed = JSON.parse(value)
1468
+ } catch {
1469
+ typed = value
1470
+ }
1471
+ params[match[1].replace(/[-_]([a-z0-9])/g, (_, char) => char.toUpperCase())] = typed
1472
+ }
1473
+ return params
1474
+ }
1475
+
1476
+ // The event a replay hands to dispatch()/runOps(). It is called DIRECTLY, not
1477
+ // re-dispatched on the element: a real event would also re-run every OTHER
1478
+ // listener on the trigger (a lazily connected sibling controller that already
1479
+ // handled the original click would toggle twice). The original's default was
1480
+ // already prevented (or deliberately kept) by early.js, so preventDefault here
1481
+ // is a no-op. A window-bound entry (issue #303) is replayed as Stimulus's
1482
+ // window listener sees it: currentTarget is the window, params come from `el`.
1483
+ function earlyReplayEvent(event, el, win) {
1484
+ return {
1485
+ [EARLY_KEY]: true,
1486
+ type: event.type,
1487
+ detail: event.detail,
1488
+ target: event.target,
1489
+ currentTarget: win ? el.ownerDocument.defaultView : el,
1490
+ params: stimulusParams(el),
1491
+ key: event.key,
1492
+ code: event.code,
1493
+ metaKey: event.metaKey,
1494
+ ctrlKey: event.ctrlKey,
1495
+ altKey: event.altKey,
1496
+ shiftKey: event.shiftKey,
1497
+ button: event.button,
1498
+ submitter: event.submitter,
1499
+ bubbles: event.bubbles,
1500
+ timeStamp: event.timeStamp,
1501
+ defaultPrevented: event.defaultPrevented,
1502
+ preventDefault() {},
1503
+ stopPropagation() {},
1504
+ stopImmediatePropagation() {},
1505
+ }
1506
+ }
1507
+
1508
+ // A :once trigger that was REPLAYED is spent, but Stimulus's own `once`
1509
+ // listener for it is still armed (the replay calls the method directly). The
1510
+ // markup is left alone — rewriting data-action would let a morph, which writes
1511
+ // the server's attribute back, re-arm it — so the spent descriptor is tracked
1512
+ // per trigger element (a WeakMap: a replaced element starts fresh, like a live
1513
+ // `once`), and the armed listener's single firing is swallowed. A window-bound
1514
+ // descriptor's live call carries the window as currentTarget, not its
1515
+ // element, so it is tracked under the ROOT instead (issue #303), flagged
1516
+ // `win` (apart from the root's own element-bound descriptors) and told apart
1517
+ // from its siblings by the params Stimulus reads off its element.
1518
+ //
1519
+ // Known limit of the root-keyed entry (the same class as onceBindingSpent's
1520
+ // window-bound limit above): a reply that MORPHS the root but swaps the
1521
+ // trigger element gives Stimulus a fresh `once` listener for the new element
1522
+ // while this entry stays armed, so the next live press is swallowed here and
1523
+ // that fresh listener is consumed — the hotkey is dead until the root
1524
+ // disconnects. Keep a `:once` hotkey's trigger element stable across replies,
1525
+ // or bind the hotkey without `once:`.
1526
+ const spentEarlyOnce = new WeakMap()
1527
+
1528
+ function spendEarlyOnce(owner, { token, type, method, filter, win }, params) {
1529
+ let spent = spentEarlyOnce.get(owner)
1530
+ if (!spent) spentEarlyOnce.set(owner, (spent = new Map()))
1531
+ const key = win ? `${token} ${params}` : token
1532
+ if (spent.has(key)) return false
1533
+ spent.set(key, { type, method, filter, win, params, armed: true })
1534
+ return true
1535
+ }
1536
+
1537
+ // True (once) for the live call that comes from a spent descriptor's listener:
1538
+ // same element, method, event type AND key filter — a sibling descriptor with
1539
+ // another filter (keydown.esc beside a spent keydown.enter:once) is not it.
1540
+ //
1541
+ // Also true for the ORIGINAL of a replayed event arriving live at the same
1542
+ // element (issue #274): waking a dormant root can connect its controller while
1543
+ // the waking event is still propagating (an eagerly registered controller
1544
+ // connects in the microtask checkpoint after early.js's capture listener), so
1545
+ // the event reaches the listener Stimulus just bound after it was replayed.
1546
+ // Only as many live calls as the replay RAN for that element and method are
1547
+ // dropped — a binding the replay skipped (a key filter only the app's own
1548
+ // mapping matches) still runs.
1549
+ function earlyOnceSwallows(event, method, keyMappings, root) {
1550
+ if (event?.[EARLY_KEY]) return false
1551
+ const win = isWindow(event?.currentTarget)
1552
+ // A window-bound replay is owned by its ROOT: another root's binding on the
1553
+ // same window event is not it (issue #303).
1554
+ const owner = win ? root : event?.currentTarget
1555
+ const replayed = takeEarlyReplay(event, owner, win ? `@${method}` : method)
1556
+ const spent = spentEarlyOnce.get(owner)
1557
+ const params = win && JSON.stringify(event.params)
1558
+ for (const entry of spent?.values() ?? []) {
1559
+ if (!entry.armed || entry.method !== method || entry.type !== event.type || Boolean(entry.win) !== win) continue
1560
+ if (win && entry.params !== params) continue
1561
+ if (entry.filter && !keyFilterMatches(entry.filter, event, keyMappings)) continue
1562
+ entry.armed = false
1563
+ return true
1564
+ }
1565
+ return replayed
1566
+ }
1567
+
1568
+ // event → (element → { dispatch: n, runOps: n }): how many bindings a replay
1569
+ // ran while its original may still be propagating. The entry lasts for that
1570
+ // propagation only — a new task starts after the whole dispatch is over — so
1571
+ // the same event OBJECT dispatched again later is live.
1572
+ const replayedEarly = new WeakMap()
1573
+
1574
+ function markEarlyReplay(event, el, method) {
1575
+ let byElement = replayedEarly.get(event)
1576
+ if (!byElement) {
1577
+ replayedEarly.set(event, (byElement = new Map()))
1578
+ setTimeout(() => replayedEarly.delete(event))
1579
+ }
1580
+ const counts = byElement.get(el) ?? {}
1581
+ byElement.set(el, counts)
1582
+ counts[method] = (counts[method] ?? 0) + 1
1583
+ }
1584
+
1585
+ function takeEarlyReplay(event, owner, method) {
1586
+ const counts = event && replayedEarly.get(event)?.get(owner)
1587
+ if (!counts?.[method]) return false
1588
+ counts[method] -= 1
1589
+ return true
1590
+ }
1591
+
1592
+ // Register this controller eagerly OR lazily: with phlex/reactive/early
1593
+ // imported, a trigger that fires before connect is replayed on connect (issue
1594
+ // #273). The engine auto-pins it with preload: true for importmap apps; see
1595
+ // the README for esbuild/webpack.
1596
+ export default class extends Controller {
1597
+ static values = {
1598
+ token: String, // signed identity token (component + record gid/state)
1599
+ }
1600
+
1601
+ #tokenCache // freshest token, threaded synchronously across queued requests
1602
+ #debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch
1603
+ #throttleTimers = new Map() // trigger element -> Map(action -> suppression timer)
1604
+ #actionPathCache // page-stable action path, resolved once per controller
1605
+ #timeoutMsCache // page-stable request timeout (ms), resolved once per controller (issue #101)
1606
+ #tokenRegexCache // { id, token, self } — #extractToken's two per-id RegExps, rebuilt on id change (issue #118)
1607
+ // Loading-state bookkeeping (issue #99). All keyed so overlapping enqueues
1608
+ // refcount correctly and never clobber each other:
1609
+ #busyPending = 0 // root aria-busy pending counter (remove only at zero)
1610
+ #busyActions = new Map() // action -> in-flight count (root's space-separated busy set + busy_on)
1611
+ #busyTokenCounts = new WeakMap() // element -> Map(action -> count): its data-reactive-busy token set
1612
+ // Dirty tracking (issue #103): the bound re-scan (turbo:morph-element) and the
1613
+ // navigate-away guard handlers, held so disconnect() can remove exactly them.
1614
+ // The root-only morph listener of a token root or lazy shell (#275, #312): a morph
1615
+ // can add a feature's marker to a connected root. Held for teardown.
1616
+ #boundRootMorph
1617
+ // Clipboard-trigger availability gate (issue #228): the bound morph re-sync,
1618
+ // held for teardown.
1619
+ // Client-only drafts (issue #239): the parsed root payload (null when
1620
+ // undeclared), the restore-complete latch (no write may run before the
1621
+ // connect restore — a connect must never overwrite a draft with server
1622
+ // blanks), the ONE per-root trailing-edge write timer, and the bound
1623
+ // input/change/turbo:submit-end handlers held for teardown.
1624
+ // Feature modules (issue #275). `featuresReady` resolves once the features
1625
+ // this root needs have connected (or been given up on: failed, or slower
1626
+ // than the timeout) — it never rejects, and is already resolved for a root
1627
+ // that needs none. connect() only STARTS the imports. The name is long on
1628
+ // purpose: it is a public field, and a short `ready` would shadow a method
1629
+ // an app's own subclass may well define.
1630
+ // #featureEpoch changes on every connect and disconnect, so an import that
1631
+ // resolves for a connection that has since ended connects nothing.
1632
+ featuresReady = FEATURES_READY
1633
+ #features = new Map() // name -> module connected on this connection
1634
+ #featuresWanted = new Set() // names this connection has asked for
1635
+ #featuresSettling = false // true while featuresReady is still pending
1636
+ #featureGate = null // settles when the last feature that `gates` has connected
1637
+ #featureWaits = new Map() // pending import-timeout timer -> its wait's resolve
1638
+ #featureHooks = new Map() // name -> [undo, pending] of a feature still on its way
1639
+ #featureTurns = new Map() // name -> settles once that feature has had its turn to connect
1640
+ #featureEpoch = 0
1641
+ #featureHandle // the `core` handle features receive, built on first use
1642
+ // The default entry (issue #305): one bit per feature, PERSIST … COMPUTE —
1643
+ // asked for (its marker read true) and connected on this connection.
1644
+ #featuresAsked = 0
1645
+ #featuresOn = 0
1646
+
1647
+ // Mark that a reactive controller actually connected, so the registration
1648
+ // guard above knows the controller was registered (issue #26 part 2).
1649
+ connect() {
1650
+ reactiveConnected = true
1651
+ // Early triggers (issue #273): from here on Stimulus delivers this root's
1652
+ // events live (its bindings are already wired), so early.js must stop
1653
+ // queueing them NOW — a connect-time seed below (a compute output write)
1654
+ // dispatches real input events. The queue itself drains at the very end.
1655
+ earlyState().connected.add(this.element)
1656
+
1657
+ // Root-id guard (issue #48). The token round trip assumes the reactive root
1658
+ // element's id == component.id: the server targets component.id and the client
1659
+ // self-matches its NEXT token by this.element.id (#extractToken, issue #46).
1660
+ // If `id:` landed on a CHILD instead of the `**reactive_attrs` root, this id is
1661
+ // "" — #extractToken falls back to the FIRST token in the response (a child's),
1662
+ // so the next action POSTs a foreign token → endpoint default-deny → silent 403.
1663
+ // Warn NOW (on connect) so the failure surfaces on page load, not on click 2.
1664
+ if (this.element.id === "") {
1665
+ console.warn(
1666
+ "[phlex-reactive] a reactive root has no id; its next-action token can't self-match " +
1667
+ "and may fall back to the first token in the response → a silent HTTP 403 on the NEXT action. " +
1668
+ "Put id: on the SAME element as reactive_attrs — use div(**reactive_root) (emits id + token together), " +
1669
+ "or div(id:, **reactive_attrs). The id: must NOT be on a child. See the README."
1670
+ )
1671
+ }
1672
+
1673
+ // Lazy shells (reactive_lazy, on:, cache:) are wired by the defer feature
1674
+ // module (issue #275), which #loadFeatures below starts importing when the
1675
+ // root carries one of its markers. What stays here is the root-only morph
1676
+ // listener of a TOKEN-BEARING root or a LAZY SHELL: a Turbo morph can turn
1677
+ // a root that connected on REAL content (no marker, so no feature) into a
1678
+ // shell while it stays connected — no Stimulus lifecycle fires — and only
1679
+ // a listener that is already there can notice and load the feature. A
1680
+ // shell carries no identity token (a `cache:` one has its URL, issue #306;
1681
+ // a plain one its defer token), and a morph can turn it, still connected,
1682
+ // into a root that needs another feature (issue #312). It costs a root
1683
+ // without any marker, before and after the morph, a few attribute reads and
1684
+ // never an import. (turbo:morph-element BUBBLES: only a morph of the root
1685
+ // itself counts.) A tokenless, client-only root has no such listener.
1686
+ if (this.element.getAttribute?.("data-reactive-token-value") != null || deferNeeded(this.element)) {
1687
+ this.#boundRootMorph = (event) => {
1688
+ if (event.target !== this.element) return
1689
+ if (__SPLIT__) this.#loadFeatures(true)
1690
+ else this.#connectFeaturesNow(true)
1691
+ }
1692
+ this.element.addEventListener?.("turbo:morph-element", this.#boundRootMorph)
1693
+ }
1694
+
1695
+ // Feature modules (issue #275): connect the ones that are here and START
1696
+ // the import of any that is not. HERE, before the seeds below, because
1697
+ // this is where their work used to sit: the draft restore (persist) writes
1698
+ // the values those seeds read, the dirty baseline (form) comes before
1699
+ // them. A module that is still on its way connects when it arrives and
1700
+ // re-runs the seeds itself (#reseed). Never awaited — connect() must reach
1701
+ // the drain at the end in the same task (issue #274: an await before it
1702
+ // runs a waking click twice).
1703
+ this.#featureEpoch++
1704
+ this.featuresReady = FEATURES_READY
1705
+ if (__SPLIT__) this.#loadFeatures()
1706
+ else this.#connectFeaturesNow()
1707
+
1708
+ // LAST, after every feature above is wired: a replayed trigger must find
1709
+ // the controller exactly as a live event after connect would.
1710
+ this.#announceConnected()
1711
+ }
1712
+
1713
+ // The default entry (issue #305): every feature is in this file, so each
1714
+ // one this root's markers ask for connects now, by a direct call, in the
1715
+ // order the opt-in entry's table gives (persist first: its restore writes
1716
+ // the values the later seeds read). Again after a morph of the root, for a
1717
+ // marker the morph added. The features with no per-root connect (effects,
1718
+ // hints, devtools) act when called.
1719
+ #connectFeaturesNow(morphed) {
1720
+ if (!__SPLIT__) {
1721
+ this.#connectFeatureNow(PERSIST, "persist", persistNeeded, (core) => persistFeature.connect(this, core, morphed))
1722
+ this.#connectFeatureNow(DEFER, "defer", deferNeeded, (core) => deferFeature.connect(this, core, morphed))
1723
+ this.#connectFeatureNow(FORM, "form", formNeeded, (core) => formFeature.connect(this, core, morphed))
1724
+ this.#connectFeatureNow(BINDINGS, "bindings", bindingsNeeded, (core) => bindingsFeature.connect(this, core, morphed))
1725
+ this.#connectFeatureNow(COMPUTE, "compute", computeNeeded, (core) => computeFeature.connect(this, core, morphed))
1726
+ }
1727
+ }
1728
+
1729
+ // Once per connection: a marker check that throws loses that feature (and
1730
+ // is read again after a morph), a connect that throws loses it until the
1731
+ // root reconnects — never the rest of connect().
1732
+ #connectFeatureNow(bit, name, needs, connect) {
1733
+ if (!__SPLIT__) {
1734
+ if (this.#featuresAsked & bit) return
1735
+ try {
1736
+ if (!needs(this.element)) return
1737
+ } catch (error) {
1738
+ return this.#featureFailed(name, "detect", error)
1739
+ }
1740
+ this.#featuresAsked |= bit
1741
+ try {
1742
+ connect(this.#featureCore())
1743
+ this.#featuresOn |= bit
1744
+ } catch (error) {
1745
+ this.#featureFailed(name, "connect", error)
1746
+ }
1747
+ }
1748
+ }
1749
+
1750
+ // FIRST in disconnect(), in connect order, while the root is still intact
1751
+ // (the draft flush reads its fields).
1752
+ #disconnectFeaturesNow() {
1753
+ if (!__SPLIT__) {
1754
+ const on = this.#featuresOn
1755
+ this.#featuresAsked = this.#featuresOn = 0
1756
+ const core = this.#featureCore()
1757
+ if (on & PERSIST) this.#disconnectFeatureNow("persist", () => persistFeature.disconnect(this, core))
1758
+ if (on & DEFER) this.#disconnectFeatureNow("defer", () => deferFeature.disconnect(this, core))
1759
+ if (on & FORM) this.#disconnectFeatureNow("form", () => formFeature.disconnect(this, core))
1760
+ if (on & BINDINGS) this.#disconnectFeatureNow("bindings", () => bindingsFeature.disconnect(this, core))
1761
+ if (on & COMPUTE) this.#disconnectFeatureNow("compute", () => computeFeature.disconnect(this, core))
1762
+ }
1763
+ }
1764
+
1765
+ #disconnectFeatureNow(name, disconnect) {
1766
+ if (!__SPLIT__) {
1767
+ try {
1768
+ disconnect()
1769
+ } catch (error) {
1770
+ console.error(`[phlex-reactive] the "${name}" feature module failed to disconnect`, error)
1771
+ }
1772
+ }
1773
+ }
1774
+
1775
+ // Starts the import of every feature this root needs and has not asked for
1776
+ // yet — at connect, and again after a morph of the root (which may have
1777
+ // added a marker; `morphed`). Every import starts now; each feature connects
1778
+ // when its own module has arrived (or is given up on) AND the features
1779
+ // before it in the table have had their turn — the order their connect-time
1780
+ // seeds depend on, never the order the network delivered them in — unless
1781
+ // this connection ended meanwhile.
1782
+ #loadFeatures(morphed) {
1783
+ if (__SPLIT__) {
1784
+ const epoch = this.#featureEpoch
1785
+ const names = []
1786
+ for (const [name, [needs]] of FEATURES) {
1787
+ if (!needs || this.#featuresWanted.has(name)) continue
1788
+ // Loaded, and with nothing to do per root: its marker need not be read.
1789
+ const loaded = featureModules.get(name)
1790
+ if (loaded && !loaded.connect) continue
1791
+ // A marker check that throws loses that feature, never the rest of
1792
+ // connect() — above all not the early drain that follows it.
1793
+ try {
1794
+ if (needs(this.element)) names.push(name)
1795
+ } catch (error) {
1796
+ this.#featureFailed(name, "detect", error)
1797
+ }
1798
+ }
1799
+ if (names.length === 0) return
1800
+ for (const name of names) this.#featuresWanted.add(name)
1801
+ // Every module already here (a later root, the next Turbo visit): connect
1802
+ // now, in this task — nothing to wait for, so nothing to gate or time out.
1803
+ if (!this.#featuresSettling && names.every((name) => featureModules.has(name))) {
1804
+ for (const name of names) this.#connectFeature(epoch, name, featureModules.get(name), morphed)
1805
+ return
1806
+ }
1807
+ for (const name of names) {
1808
+ const arrival = this.#awaitFeature(epoch, name)
1809
+ // Its turn comes after the features BEFORE it in the table that this
1810
+ // root is still waiting for — never after a later one, even one whose
1811
+ // import started earlier (a morph can add an earlier feature's marker).
1812
+ const earlier = []
1813
+ for (const key of FEATURES.keys()) {
1814
+ if (key === name) break
1815
+ if (this.#featureTurns.has(key)) earlier.push(this.#featureTurns.get(key))
1816
+ }
1817
+ const turn = Promise.all(earlier)
1818
+ .then(() => arrival)
1819
+ .then((feature) => feature && this.#connectFeature(epoch, name, feature, morphed))
1820
+ this.#featureTurns.set(name, turn)
1821
+ if (FEATURES.get(name)[3]) this.#featureGate = turn
1822
+ }
1823
+ // Requests stop waiting the moment the last gating feature has connected.
1824
+ const gate = this.#featureGate
1825
+ gate?.then(() => {
1826
+ if (this.#featureGate === gate) this.#featureGate = null
1827
+ })
1828
+ this.#featuresSettling = true
1829
+ const ready = Promise.all(this.#featureTurns.values()).then(() => {
1830
+ if (this.featuresReady === ready) this.#featuresSettling = false
1831
+ })
1832
+ this.featuresReady = ready
1833
+ }
1834
+ }
1835
+
1836
+ // One feature's import, for one scan: resolves with the module, or with
1837
+ // null once the import failed or outlasted the timeout — so one slow or
1838
+ // broken feature never holds back the others. A module that arrives after
1839
+ // its timeout still connects (late, and so out of table order).
1840
+ #awaitFeature(epoch, name) {
1841
+ if (__SPLIT__) {
1842
+ const current = () => epoch === this.#featureEpoch
1843
+ this.#startWaitingFor(name)
1844
+ return new Promise((resolve) => {
1845
+ let timedOut = false
1846
+ const timer = setTimeout(() => {
1847
+ this.#featureWaits.delete(timer)
1848
+ timedOut = true
1849
+ if (current()) this.#featureFailed(name, "timeout", new Error(`not loaded after ${featureTimeoutMs()} ms`))
1850
+ resolve(null)
1851
+ }, featureTimeoutMs())
1852
+ // Kept with its resolve: a disconnect ends the wait (#disconnectFeatures),
1853
+ // or whatever is queued behind featuresReady would hang on a stuck import.
1854
+ this.#featureWaits.set(timer, resolve)
1855
+ const settled = () => {
1856
+ clearTimeout(timer)
1857
+ this.#featureWaits.delete(timer)
1858
+ }
1859
+ loadFeature(name).then(
1860
+ (feature) => {
1861
+ settled()
1862
+ // Late: connect one microtask on, after whatever else was queued on
1863
+ // this import while the root waited.
1864
+ if (timedOut) queueMicrotask(() => this.#connectFeature(epoch, name, feature))
1865
+ else resolve(feature)
1866
+ },
1867
+ (error) => {
1868
+ settled()
1869
+ if (current()) this.#featureFailed(name, "load", error)
1870
+ resolve(null)
1871
+ },
1872
+ )
1873
+ })
1874
+ }
1875
+ }
1876
+
1877
+ // Run the feature's `waiting` hook (if it has one) and remember its undo and
1878
+ // what it records, for #connectFeature or #disconnectFeatures to hand on.
1879
+ #startWaitingFor(name) {
1880
+ if (__SPLIT__) {
1881
+ const hook = FEATURES.get(name)[2]
1882
+ if (!hook) return
1883
+ const pending = {}
1884
+ try {
1885
+ this.#featureHooks.set(name, [hook(this.element, pending), pending])
1886
+ FEATURE_SHARED.waiting.set(this.element, { ...FEATURE_SHARED.waiting.get(this.element), [name]: pending })
1887
+ } catch (error) {
1888
+ this.#featureFailed(name, "detect", error)
1889
+ }
1890
+ }
1891
+ }
1892
+
1893
+ // The wait is over (the feature is here, or the root is leaving): undo the
1894
+ // hook and return what it recorded. The shared record goes with it unless
1895
+ // `abandoning`: a root that leaves keeps its record until abandon() has run,
1896
+ // so anything else queued on the same import still finds it.
1897
+ #stopWaitingFor(name, abandoning) {
1898
+ if (__SPLIT__) {
1899
+ const [undo, pending] = this.#featureHooks.get(name) ?? []
1900
+ this.#featureHooks.delete(name)
1901
+ if (!abandoning) forgetWaiting(this.element, name, pending)
1902
+ try {
1903
+ undo?.()
1904
+ } catch (error) {
1905
+ console.error(`[phlex-reactive] the "${name}" feature module failed to stop waiting`, error)
1906
+ }
1907
+ return pending
1908
+ }
1909
+ }
1910
+
1911
+ #connectFeature(epoch, name, feature, morphed) {
1912
+ if (__SPLIT__) {
1913
+ if (epoch !== this.#featureEpoch) return
1914
+ const pending = this.#stopWaitingFor(name)
1915
+ try {
1916
+ feature.connect?.(this, this.#featureCore(), morphed, pending)
1917
+ this.#features.set(name, feature)
1918
+ } catch (error) {
1919
+ this.#featureFailed(name, "connect", error)
1920
+ }
1921
+ }
1922
+ }
1923
+
1924
+ // The `core` handle a feature receives (see "Feature modules" above).
1925
+ #featureCore() {
1926
+ this.#featureHandle ??= {
1927
+ emit: (name, detail, options) => this.#emit(name, detail, options),
1928
+ reseed: () => this.#reseed(),
1929
+ proceed: (target, action, params) => this.#proceed(target, action, params),
1930
+ forgetToken: () => {
1931
+ this.#tokenCache = undefined
1932
+ },
1933
+ owns: (el) => this.#ownsField(el),
1934
+ ownership: () => this.#ownershipFilter(),
1935
+ opTargets: (args) => this.#opTargets(args),
1936
+ diagnose: (label, args) => this.#diagnoseZeroTargets(label, args),
1937
+ // Apply an op list root-scoped, with the zero-target diagnostics; a
1938
+ // missing to: defaults to `defaultTo` (the reducer / completion-binding
1939
+ // convention is "@root").
1940
+ applyOps: (list, defaultTo) =>
1941
+ applyOps(
1942
+ list,
1943
+ (args) => this.#opTargets(defaultTo != null && args.to == null ? { ...args, to: defaultTo } : args),
1944
+ (name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
1945
+ ),
1946
+ listnavOptions: (event) => this.#listnavOptions(event),
1947
+ collectFields: () => this.#collectFields(),
1948
+ // The confirm gate, as dispatch() runs it: the resolver inside the chain
1949
+ // (a synchronous throw is a cancel), never a rejection out.
1950
+ confirm: (message, context) =>
1951
+ Promise.resolve()
1952
+ .then(() => confirmResolver(message, context))
1953
+ .catch(() => false),
1954
+ }
1955
+ return this.#featureHandle
1956
+ }
1957
+
1958
+ // Re-run the connect-time seeds that read field values, in connect() order
1959
+ // — for a feature that changed those values after connect() ran (the draft
1960
+ // restore). Each is the same re-sync its feature runs after a morph; a root
1961
+ // that did not opt into one skips it. on-complete re-ARMS without firing.
1962
+ #reseed() {
1963
+ if (!__SPLIT__) {
1964
+ if (this.#featuresOn & FORM) formFeature.scan(this, this.#featureCore())
1965
+ if (this.#featuresOn & BINDINGS) bindingsFeature.reseed(this)
1966
+ if (this.#featuresOn & COMPUTE) computeFeature.seed(this)
1967
+ } else {
1968
+ this.#features.get("form")?.scan(this, this.#featureCore())
1969
+ this.#features.get("bindings")?.reseed(this)
1970
+ this.#features.get("compute")?.seed(this)
1971
+ }
1972
+ }
1973
+
1974
+ // A feature that is missing leaves its part of the root dead: say so on
1975
+ // every root it costs (reactive:error, the error marker), and once in the
1976
+ // console. `phase` is "detect", "load", "timeout" or "connect".
1977
+ #featureFailed(name, phase, error) {
1978
+ logFeatureFailure(name, phase, error)
1979
+ this.#markError("feature")
1980
+ // A lazy shell whose module will never come (a failed import stays failed
1981
+ // until the page is reloaded) must not go on shimmering as if it loaded.
1982
+ if (phase === "load" && this.element.getAttribute?.("data-reactive-defer-pending")) {
1983
+ this.element.removeAttribute("data-reactive-defer-pending")
1984
+ this.element.removeAttribute("aria-busy")
1985
+ }
1986
+ this.#emit("reactive:error", { kind: "feature", feature: name, phase, error })
1987
+ }
1988
+
1989
+ // Runs FIRST in disconnect(), in table order, while the root is still
1990
+ // intact (the draft flush reads its fields). A feature whose disconnect
1991
+ // throws must not keep the others, or the rest of disconnect(), from running.
1992
+ #disconnectFeatures() {
1993
+ if (__SPLIT__) {
1994
+ this.#featureEpoch++
1995
+ // End every wait still open: nothing will connect on this connection, and
1996
+ // a request queued behind featuresReady must not hang on a stuck import.
1997
+ for (const [timer, resolve] of this.#featureWaits) {
1998
+ clearTimeout(timer)
1999
+ resolve(null)
2000
+ }
2001
+ this.#featureWaits.clear()
2002
+ // A feature this root was still waiting for never connected here: hand it
2003
+ // what its hook recorded, once (and if) the module arrives.
2004
+ for (const name of [...this.#featureHooks.keys()]) {
2005
+ const pending = this.#stopWaitingFor(name, true)
2006
+ const root = this.element
2007
+ withFeature(name, (feature) => {
2008
+ feature.abandon?.(root, pending)
2009
+ forgetWaiting(root, name, pending)
2010
+ })
2011
+ }
2012
+ this.#featuresWanted.clear()
2013
+ this.#featureTurns.clear()
2014
+ this.#featuresSettling = false
2015
+ this.#featureGate = null
2016
+ const connected = [...this.#features]
2017
+ this.#features.clear()
2018
+ for (const [name, feature] of connected) {
2019
+ try {
2020
+ feature.disconnect?.(this, this.#featureCore())
2021
+ } catch (error) {
2022
+ console.error(`[phlex-reactive] the "${name}" feature module failed to disconnect`, error)
2023
+ }
2024
+ }
2025
+ }
2026
+ }
2027
+
2028
+ // Early triggers (issue #273): mark the root (the attribute is for CSS and
2029
+ // tests; early.js trusts the WeakSet joined at the top of connect()),
2030
+ // announce it with a bubbling reactive:connect, then replay its queued triggers.
2031
+ #announceConnected() {
2032
+ // The attribute is a connect-time marker only: an in-place morph writes
2033
+ // the server's attributes back and strips it (re-marking would cost every
2034
+ // root a morph listener — the "a root that never opted in pays nothing"
2035
+ // contract). The connected WeakSet is the truth; reactive:connect the signal.
2036
+ this.element.setAttribute?.("data-reactive-connected", "")
2037
+ // Raw dispatch (as #emit), on the root only: connect() runs on an attached
2038
+ // element, so there is no detached-node fallback to make.
2039
+ if (this.element.isConnected) {
2040
+ this.element.dispatchEvent?.(
2041
+ new CustomEvent("reactive:connect", { bubbles: true, composed: true, detail: { id: this.element.id } }),
2042
+ )
2043
+ }
2044
+ this.#drainEarly()
2045
+ }
2046
+
2047
+ // The trigger elements whose :once descriptor THIS connection replayed —
2048
+ // forgotten on disconnect (see disconnect()).
2049
+ #earlySpentOn = new Set()
2050
+
2051
+ // Takes this root's entries — and those of any root that left the page
2052
+ // before connecting (replaced by a stream), which no controller would ever
2053
+ // claim.
2054
+ #drainEarly() {
2055
+ const { queue } = earlyState()
2056
+ if (queue.length === 0) return
2057
+ const mine = []
2058
+ for (let i = queue.length - 1; i >= 0; i--) {
2059
+ const { root } = queue[i]
2060
+ if (root === this.element || !root.isConnected) mine.unshift(...queue.splice(i, 1))
2061
+ }
2062
+ const ttl = earlyTtlMs()
2063
+ // Two module instances of early.js (a bundled copy beside the pinned one)
2064
+ // each queue the same event: replay an (event, element) pair once. (One
2065
+ // event OBJECT dispatched twice on one element before connect also counts
2066
+ // once — indistinguishable here.) An element bound to both `click` and
2067
+ // `click@window` is heard by both listeners live, so its window-bound
2068
+ // entry (issue #303) is a separate pair.
2069
+ const replayed = new Map()
2070
+ for (const entry of mine) {
2071
+ const pair = replayed.get(entry.event) ?? [new Set(), new Set()]
2072
+ replayed.set(entry.event, pair)
2073
+ const seen = pair[+!!entry.win]
2074
+ if (seen.has(entry.el)) continue
2075
+ seen.add(entry.el)
2076
+ const limit = entry.win ? Math.min(WINDOW_EARLY_TTL_MS, ttl) : ttl
2077
+ const reason =
2078
+ entry.root !== this.element
2079
+ ? "its root left the page before a controller connected"
2080
+ : performance.now() - entry.at > limit
2081
+ ? `it is older than the ${limit} ms ${entry.win ? "window-trigger" : "early-event"} TTL`
2082
+ : entry.el.isConnected && this.element.contains(entry.el)
2083
+ ? null
2084
+ : "its element left the root before the controller connected"
2085
+ if (reason) {
2086
+ if (this.#verboseEnabled()) console.warn(`[phlex-reactive] dropped an early "${entry.event.type}" trigger: ${reason}`)
2087
+ continue
2088
+ }
2089
+ this.#replayEarly(entry)
2090
+ }
2091
+ }
2092
+
2093
+ // One queued event, replayed once per matching descriptor — Stimulus calls
2094
+ // each binding with the SAME event object, so runOps' duplicate-binding
2095
+ // guard behaves as it does live. Each descriptor is re-checked here, where
2096
+ // Stimulus would check it: it must still be on the element (a morph may
2097
+ // have removed it), its key filter must match under the app's own key
2098
+ // mappings (early.js knows only Stimulus's defaults), and a :once one runs a single time however often it
2099
+ // was queued (spendEarlyOnce).
2100
+ #replayEarly({ event, el, descs, win }) {
2101
+ const replay = earlyReplayEvent(event, el, win)
2102
+ const keyMappings = this.application?.schema?.keyMappings
2103
+ const tokens = (el.getAttribute("data-action") ?? "").split(/\s+/)
2104
+ // A window-bound entry belongs to this root, not to `el` (issue #303).
2105
+ const owner = win ? this.element : el
2106
+ for (const desc of descs) {
2107
+ if (!tokens.includes(desc.token)) continue
2108
+ if (desc.filter && !keyFilterMatches(desc.filter, event, keyMappings)) continue
2109
+ // (`:once` is read off the token: early.js keeps its records minimal.)
2110
+ if (/#\w+.*:once\b/.test(desc.token)) {
2111
+ if (!spendEarlyOnce(owner, desc, win && JSON.stringify(replay.params))) continue
2112
+ this.#earlySpentOn.add(owner)
2113
+ }
2114
+ // The original may still be propagating (issue #274): its live arrival at
2115
+ // `el` — or at the window, for a window-bound entry — must not run this
2116
+ // binding again (earlyOnceSwallows).
2117
+ markEarlyReplay(event, owner, win ? `@${desc.method}` : desc.method)
2118
+ if (desc.method === "runOps") this.runOps(replay)
2119
+ else this.dispatch(replay)
2120
+ }
2121
+ }
2122
+
2123
+ // Tear down any pending debounce timers when the controller leaves the DOM
2124
+ // (Turbo morph/navigation removes the element). Otherwise a timer that hasn't
2125
+ // fired yet would later call #enqueue on a disconnected controller — a round
2126
+ // trip against a detached element / stale token (issue #17 follow-up).
2127
+ // Throttle suppression timers (issue #80) are torn down the same way — a
2128
+ // leading-edge timer holds no pending POST, but leaving it running would leak
2129
+ // it past the element's life.
2130
+ disconnect() {
2131
+ // Features FIRST (issue #275): the persist feature flushes a pending draft
2132
+ // write while the fields are still readable (Turbo disconnects before
2133
+ // leaving the page — a fast visit otherwise loses the last keystrokes).
2134
+ if (__SPLIT__) this.#disconnectFeatures()
2135
+ else this.#disconnectFeaturesNow()
2136
+ this.#clearAllDebounces()
2137
+ this.#clearAllThrottles()
2138
+ if (this.#boundRootMorph) {
2139
+ this.element.removeEventListener?.("turbo:morph-element", this.#boundRootMorph)
2140
+ }
2141
+ // Early triggers (issue #273): a disconnected root records again.
2142
+ earlyState().connected.delete(this.element)
2143
+ this.element.removeAttribute?.("data-reactive-connected")
2144
+ // A spent :once replay is remembered per element so a morph REPLY (root
2145
+ // still connected) cannot re-arm it. A disconnect drops Stimulus's own
2146
+ // `once` listeners and the next connect binds fresh ones, so the memory
2147
+ // goes too (issue #274: a dormant morph-back disconnects a root in place —
2148
+ // its :once trigger must work again after the re-wake).
2149
+ for (const el of this.#earlySpentOn) spentEarlyOnce.delete(el)
2150
+ this.#earlySpentOn.clear()
2151
+ }
2152
+
2153
+ // Serialize requests per component. Each round trip rewrites the signed
2154
+ // token in the DOM (state lives in the token, not the client). If events
2155
+ // fire faster than round trips complete, concurrent requests would all read
2156
+ // the SAME stale token and clobber each other (last-write-wins). Chaining on
2157
+ // a per-controller promise makes each dispatch wait for the previous one, so
2158
+ // it always uses the freshest token.
2159
+ dispatch(event) {
2160
+ // A :once trigger already replayed on connect (issue #273) is spent.
2161
+ if (earlyOnceSwallows(event, "dispatch", this.application?.schema?.keyMappings, this.element)) return
2162
+ // `window` (renamed: never shadow the global) and `outside` are the event-
2163
+ // modifier params (issue #80). The client decides preventDefault behavior
2164
+ // from event.params — set by the Ruby on() — never by sniffing the
2165
+ // Stimulus descriptor.
2166
+ const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic } =
2167
+ event.params
2168
+ if (!action) return
2169
+
2170
+ // The pending-state hint (issue #181): data-reactive-busy-param. During a
2171
+ // deploy overlap a page rendered by the PREVIOUS gem still emits the old
2172
+ // data-reactive-loading-param — read it as a fallback so an in-flight page
2173
+ // keeps its pending affordance until the next full render (the hints
2174
+ // feature remaps its `class:` key).
2175
+ const busy = event.params.busy ?? event.params.loading
2176
+
2177
+ // Outside guard FIRST (issue #80): an outside: trigger only fires for
2178
+ // events whose target is OUTSIDE this component's ROOT (containment against
2179
+ // this.element — .contains includes the root itself). An event inside the
2180
+ // root must be a COMPLETE no-op — before preventDefault (the page's native
2181
+ // click behavior is untouched) and before the reactive:before-dispatch
2182
+ // lifecycle event (nothing to announce, nothing to veto).
2183
+ if (outside && this.element.contains(event.target)) return
2184
+
2185
+ // The trigger is event.currentTarget — the element on(...) was spread onto —
2186
+ // NOT event.target (issue #99). A `<button><span>Save</span></button>` click
2187
+ // has target === the span, which carries no params and is the wrong element
2188
+ // to disable / swap text on. currentTarget is the bound element; fall back to
2189
+ // target for a directly-invoked/synthetic event. Captured now because
2190
+ // #proceed runs in a later microtask (after the confirm resolver), by which
2191
+ // point currentTarget is reset to null.
2192
+ const target = event.currentTarget ?? event.target
2193
+
2194
+ // Stop native behavior (button submit / FORM NAVIGATION) HERE, synchronously
2195
+ // within the event dispatch — BEFORE the (possibly async) confirm gate below.
2196
+ // preventDefault() only works while the event is still being handled; once we
2197
+ // await the confirm resolver it's too late, and a `submit` trigger would
2198
+ // natively POST the form and navigate before the reactive round trip runs
2199
+ // (issue #11). For a `click` trigger there's no default to miss. This holds
2200
+ // for debounced triggers too — the round trip is deferred, but the native
2201
+ // default must still be prevented now. (Moved ahead of the confirm branch in
2202
+ // issue #55: an async resolver means we can't preventDefault after awaiting.)
2203
+ //
2204
+ // ONLY for element-bound triggers: a window-bound trigger (window:/outside:,
2205
+ // issue #80) hears EVERY matching event on the page — preventDefault-ing
2206
+ // those would kill every link click while a dropdown is mounted. The page's
2207
+ // native behavior proceeds alongside the reactive round trip.
2208
+ //
2209
+ // The `checked: :keep` optimistic hint (issue #98) OPTS OUT: for a click-bound
2210
+ // checkbox/radio the unconditional preventDefault is exactly what stops the
2211
+ // native flip from happening before the morph — so a bare checkbox click
2212
+ // (which has no form-navigation default to lose) skips it and flips now, and
2213
+ // the failure revert snaps it back. A `change`-bound trigger is unaffected —
2214
+ // `change` isn't cancelable, so preventDefault was already a no-op there.
2215
+ if (!windowBound && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
2216
+
2217
+ // A reactive_lazy(on:) shell's trigger (issue #276) goes through the one
2218
+ // materialize entry point, which dedupes it against the observer, the
2219
+ // re-armed listener and a morph-back. That entry point lives in the defer
2220
+ // feature (issue #275): the synchronous part of this dispatch is done, and
2221
+ // the load itself waits for the feature while it is still on its way.
2222
+ if (action === LAZY_MATERIALIZE_ACTION) {
2223
+ // Without the feature (it failed to load) a shell can still load the
2224
+ // plain way: the signed __materialize POST. A `cache:` shell carries no
2225
+ // token (issue #306) and has no such way: the feature's reactive:error
2226
+ // already said so, and nothing is sent.
2227
+ const materialize = () => {
2228
+ // (A shell whose defer connect threw has nothing wired: the plain POST.)
2229
+ if (!__SPLIT__) {
2230
+ if (this.#featuresOn & DEFER) return deferFeature.materialize(this)
2231
+ } else {
2232
+ const defer = this.#features.get("defer")
2233
+ if (defer) return defer.materialize(this)
2234
+ }
2235
+ if (this.tokenValue) return this.#proceed(target, action, "{}")
2236
+ }
2237
+ return this.#featuresSettling ? this.featuresReady.then(materialize) : materialize()
2238
+ }
2239
+
2240
+ // Resolve the EFFECTIVE confirm message (issue #179): a plain string confirm:
2241
+ // is that string (static, #52); a Hash confirm: (confirmWhen) evaluates its
2242
+ // condition/predicate over the collected fields and returns the message ONLY
2243
+ // when it fires, else null → no dialog. No confirm at all → also null. The
2244
+ // conditional form is the bindings feature's: on the opt-in client its
2245
+ // answer may come a moment later (a promise), once the module is here.
2246
+ const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
2247
+ const proceed = () => this.#proceed(target, action, params, debounce, throttle, optimistic, busy)
2248
+
2249
+ // No message → proceed straight away (unchanged fast path).
2250
+ if (!message) return proceed()
2251
+ if (message instanceof Promise) {
2252
+ return message.then((resolved) => (resolved ? this.#confirmThen(resolved, target, proceed) : proceed()))
2253
+ }
2254
+ this.#confirmThen(message, target, proceed)
2255
+ }
2256
+
2257
+ // The confirmation gate (issue #52, made overridable + async in #55). A
2258
+ // reactive trigger can't use Hotwire's data-turbo-confirm — this controller
2259
+ // preempts the event — so a `confirm:` message routes through
2260
+ // confirmResolver (default window.confirm; an app can override it to reuse
2261
+ // Turbo.config.forms.confirm). The resolver may be sync or async; call it
2262
+ // INSIDE the chain (via the leading .then) so even a SYNCHRONOUS override
2263
+ // throw rejects this promise instead of escaping dispatch — a throwing
2264
+ // dialog is treated as a cancel, like the user dismissing it. The .catch is
2265
+ // scoped to the resolver step (→ false = cancel), so a dismissed/erroring
2266
+ // dialog never surfaces as an unhandled rejection AND a genuine bug inside
2267
+ // #proceed is NOT silently swallowed. Enqueue ONLY on a truthy resolution —
2268
+ // nothing is enqueued, no timer scheduled, otherwise. The resolver's
2269
+ // optional 2nd arg (issue #222) carries the trigger element, so an override
2270
+ // has the same ctx shape here as on nestedRemove ({ el, … }).
2271
+ #confirmThen(message, target, proceed) {
2272
+ return Promise.resolve()
2273
+ .then(() => confirmResolver(message, { el: target }))
2274
+ .catch(() => false)
2275
+ .then((ok) => {
2276
+ if (ok) proceed()
2277
+ })
2278
+ }
2279
+
2280
+ // The effective confirm message (issue #179): the static string; or, for a
2281
+ // conditional confirm, what the bindings feature says — now when the module
2282
+ // is here, else a promise of it (the opt-in client's import window); or
2283
+ // null when neither applies. A module that cannot load, or is slower than
2284
+ // the feature timeout, answers null: no dialog, the endpoint's
2285
+ // authorize/default-deny is the real gate.
2286
+ #effectiveConfirmMessage(confirm, confirmWhen) {
2287
+ if (confirm) return confirm
2288
+ if (!confirmWhen) return null
2289
+ if (!__SPLIT__) return bindingsFeature.confirmMessage(this, this.#featureCore(), confirmWhen)
2290
+ else {
2291
+ const loaded = featureModules.get("bindings")
2292
+ if (loaded) return loaded.confirmMessage(this, this.#featureCore(), confirmWhen)
2293
+ return this.#awaitFeatureOrNull("bindings").then(
2294
+ (bindings) => bindings && bindings.confirmMessage(this, this.#featureCore(), confirmWhen),
2295
+ )
2296
+ }
2297
+ }
2298
+
2299
+ // A feature's module, or null once its import failed (logged) or outlasted
2300
+ // the feature timeout — for the two places a REQUEST waits on an import
2301
+ // without a root connection to wait with (a hint, a conditional confirm).
2302
+ #awaitFeatureOrNull(name) {
2303
+ if (__SPLIT__) {
2304
+ let timer
2305
+ return Promise.race([
2306
+ loadFeature(name).catch((error) => (logFeatureFailure(name, "load", error), null)),
2307
+ new Promise((resolve) => (timer = setTimeout(() => resolve(null), featureTimeoutMs()))),
2308
+ ]).finally(() => clearTimeout(timer))
2309
+ }
2310
+ }
2311
+
2312
+
2313
+ // CLIENT-ONLY trigger entry point (issue #95) — the zero-round-trip sibling
2314
+ // of dispatch(). Wired by on_client: applies the declared op chain
2315
+ // (data-reactive-ops-param, built by Phlex::Reactive::JS) locally. NO token,
2316
+ // NO params, NO fetch, ever. Ops are ephemeral UI: any server re-render of
2317
+ // the component resets whatever they toggled (by design — a signed action
2318
+ // owns state that must survive re-renders).
2319
+ runOps(event) {
2320
+ if (earlyOnceSwallows(event, "runOps", this.application?.schema?.keyMappings, this.element)) return
2321
+ if (bindingsAlreadyRan(event, this)) return
2322
+ const params = event.params ?? {}
2323
+ // The trigger element on_client was spread onto (issue #222 ctx: { el }),
2324
+ // captured now — currentTarget resets before the confirm resolver's microtask.
2325
+ const trigger = event.currentTarget ?? event.target
2326
+ // Issue #271: run only the binding record(s) whose descriptor fired. A
2327
+ // legacy [[op, args]] attr reads its flags from the element-wide params.
2328
+ const records = parseBindingRecords(params.ops)
2329
+ // Stimulus already applied the firing descriptor's key filter (custom
2330
+ // schema keys included), so a lone candidate on type + window-boundness
2331
+ // runs without a second key check; several same-type candidates are told
2332
+ // apart by their key filters, read through the app's Stimulus schema.
2333
+ const candidates = records.filter((record) => bindingMatches(record, event, null))
2334
+ const matching =
2335
+ candidates.length <= 1
2336
+ ? candidates
2337
+ : candidates.filter((record) => bindingMatches(record, event, this.application?.schema?.keyMappings))
2338
+ if (matching.length === 0 && records.length > 0) {
2339
+ // Issue #271: a hand-edited attr or a descriptor the matcher doesn't
2340
+ // know. Verbose gate only (the devtools feature dedupes).
2341
+ if (this.#verboseEnabled()) {
2342
+ if (__SPLIT__) this.#devtools((devtools) => devtools.noBinding(this, event))
2343
+ else devtoolsFeature.noBinding(this, event)
2344
+ }
2345
+ return
2346
+ }
2347
+ for (const record of matching) {
2348
+ // An early replay never runs an outside: record (issue #303: an outside
2349
+ // click before connect had nothing to close).
2350
+ if (record.outside && event[EARLY_KEY]) continue
2351
+ if (onceBindingSpent(this, event, record)) continue
2352
+ this.#runBinding(record.legacy ? { ...params, ops: record.ops } : record, event, trigger)
2353
+ }
2354
+ }
2355
+
2356
+ // One on_client binding (issue #95): its outside guard, preventDefault rule,
2357
+ // confirm gate, then its ops — every flag read from the binding itself.
2358
+ #runBinding({ ops, confirm, confirmWhen, outside, window: windowBound }, event, trigger) {
2359
+ // Outside guard FIRST — identical semantics to dispatch() (issue #80): an
2360
+ // outside: trigger is a COMPLETE no-op for events inside this root, before
2361
+ // preventDefault and before any op runs.
2362
+ if (outside && this.element.contains(event.target)) return
2363
+
2364
+ // Element-bound triggers preventDefault (a bare button inside a <form>
2365
+ // must not submit it); window-bound triggers (window:/outside:) never do —
2366
+ // they hear every matching event on the page, and preventDefault-ing those
2367
+ // would kill native clicks site-wide (issue #80 rationale). Runs BEFORE the
2368
+ // (possibly async) confirm gate below — a native default can't wait for a
2369
+ // pending dialog (same ordering as dispatch()).
2370
+ if (!windowBound) event.preventDefault()
2371
+
2372
+ // Resolve the effective confirm message — static string, or the conditional
2373
+ // Hash form (issue #179) evaluated over collected fields (a promise of it
2374
+ // on the opt-in client while the bindings module is on its way). Null →
2375
+ // no dialog. The gate is here (the user gesture), NOT in #applyOps: that
2376
+ // applier is shared with the server-pushed reactive:js stream action,
2377
+ // which must NEVER prompt. The same confirmResolver gate on(:action,
2378
+ // confirm:) uses (issues #52/#55/#178), so a themed dialog covers both.
2379
+ const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
2380
+ const apply = () => this.#applyOps(this.#parseOps(ops))
2381
+ if (!message) return apply()
2382
+ if (message instanceof Promise) {
2383
+ return message.then((resolved) => (resolved ? this.#confirmThen(resolved, trigger, apply) : apply()))
2384
+ }
2385
+ this.#confirmThen(message, trigger, apply)
2386
+ }
2387
+
2388
+ // Client-side compute (issue #104) is the compute feature's (issue #275,
2389
+ // features/compute.js). This is the method its descriptor names
2390
+ // (input->reactive#recompute), so it stays. While the module is on its way
2391
+ // (the opt-in client) the entry records the edit, and the module runs one
2392
+ // recompute when it connects.
2393
+ recompute(event) {
2394
+ return __SPLIT__
2395
+ ? featureModules.get("compute")?.recompute(this, this.#featureCore(), event)
2396
+ : computeFeature.recompute(this, this.#featureCore(), event)
2397
+ }
2398
+
2399
+ // Tag-chip input (issue #203) and draft nested rows (issue #208) are the
2400
+ // bindings feature's (issue #275, features/bindings.js). These are the
2401
+ // methods their descriptors name, so they stay: each hands the event to the
2402
+ // module — now, or, with the opt-in client, once it has arrived. The event
2403
+ // is dead by then, so what the module reads of it (the trigger, the target)
2404
+ // is captured here; the native default is prevented now, as the module
2405
+ // would have. tagsAdd keeps its two composition guards (an Enter that
2406
+ // listnav already took, or is about to take) in the same tick.
2407
+ tagsAdd(event) {
2408
+ if (event?.defaultPrevented) return
2409
+ if (this.#listnavOptions(event).some((el) => el.hasAttribute?.("data-reactive-highlighted"))) return
2410
+ return __SPLIT__
2411
+ ? this.#bindingsAction("tagsAdd", event)
2412
+ : bindingsFeature.tagsAdd(this, this.#featureCore(), event)
2413
+ }
2414
+
2415
+ tagsPick(event) {
2416
+ return __SPLIT__
2417
+ ? this.#bindingsAction("tagsPick", event)
2418
+ : bindingsFeature.tagsPick(this, this.#featureCore(), event)
2419
+ }
2420
+
2421
+ tagsRemove(event) {
2422
+ return __SPLIT__
2423
+ ? this.#bindingsAction("tagsRemove", event)
2424
+ : bindingsFeature.tagsRemove(this, this.#featureCore(), event)
2425
+ }
2426
+
2427
+ nestedAdd(event) {
2428
+ return __SPLIT__
2429
+ ? this.#bindingsAction("nestedAdd", event)
2430
+ : bindingsFeature.nestedAdd(this, this.#featureCore(), event)
2431
+ }
2432
+
2433
+ nestedRemove(event) {
2434
+ return __SPLIT__
2435
+ ? this.#bindingsAction("nestedRemove", event)
2436
+ : bindingsFeature.nestedRemove(this, this.#featureCore(), event)
2437
+ }
2438
+
2439
+ syncNestedJson(event) {
2440
+ return __SPLIT__
2441
+ ? featureModules.get("bindings")?.syncNestedJson(this, this.#featureCore(), event)
2442
+ : bindingsFeature.syncNestedJson(this, this.#featureCore(), event)
2443
+ }
2444
+
2445
+ #bindingsAction(name, event) {
2446
+ if (__SPLIT__) {
2447
+ const loaded = featureModules.get("bindings")
2448
+ if (loaded) return loaded[name](this, this.#featureCore(), event)
2449
+ event?.preventDefault?.()
2450
+ const snapshot = { currentTarget: event?.currentTarget ?? null, target: event?.target ?? null, preventDefault() {} }
2451
+ return withFeature("bindings", (bindings) => bindings[name](this, this.#featureCore(), snapshot))
2452
+ }
2453
+ }
2454
+
2455
+ // Dirty tracking (issue #103) is the form feature's (issue #275,
2456
+ // features/form.js). This is the method its descriptor names
2457
+ // (input->reactive#trackDirty), so it stays: a full re-scan, by the module
2458
+ // when it is here. While it is still on its way nothing is lost — it scans
2459
+ // the whole root when it connects.
2460
+ trackDirty() {
2461
+ if (!__SPLIT__) formFeature.scan(this, this.#featureCore())
2462
+ else (this.#features.get("form") ?? featureModules.get("form"))?.scan(this, this.#featureCore())
2463
+ }
2464
+
2465
+ // Client-side list navigation (combobox keyboard nav, issue #72). Wired by
2466
+ // on(:search, …, listnav: "[role=option]"), which appends keyboard filters to
2467
+ // the input's data-action (keydown.down/up/enter/esc->reactive#listnav*) and
2468
+ // sets data-reactive-listnav-option-param. Arrow keys move a highlight among
2469
+ // the options WITH NO ROUND TRIP; Enter picks the highlighted option by
2470
+ // CLICKING IT (so its own on(:select) reactive trigger fires — selection stays
2471
+ // a signed action); Escape clears. Ephemeral highlight state lives on the DOM
2472
+ // (data-reactive-highlighted), never shipped to the client as trusted state.
2473
+ //
2474
+ // Issue #271: a container spread with reactive_listnav(focus: true) carries
2475
+ // data-reactive-listnav-focus-param="true" — ROVING-FOCUS mode for a
2476
+ // role=menu: the same moves shift real focus among the items instead of a
2477
+ // highlight, and Home/End (listnavFirst/listnavLast) jump to the edges.
2478
+ listnavNext(event) {
2479
+ this.#listnavMove(event, (current, length) => (current < 0 ? 0 : (current + 1) % length))
2480
+ }
2481
+
2482
+ listnavPrev(event) {
2483
+ this.#listnavMove(event, (current, length) => (current < 0 ? length - 1 : (current - 1 + length) % length))
2484
+ }
2485
+
2486
+ listnavFirst(event) {
2487
+ this.#listnavMove(event, () => 0)
2488
+ }
2489
+
2490
+ listnavLast(event) {
2491
+ this.#listnavMove(event, (_current, length) => length - 1)
2492
+ }
2493
+
2494
+ // Enter: activate the highlighted option (fires its reactive select). No-op if
2495
+ // nothing is highlighted, and in that case DON'T preventDefault — Enter falls
2496
+ // through (there's no selection to make).
2497
+ listnavPick(event) {
2498
+ const options = this.#listnavOptions(event)
2499
+ const current = options.findIndex((el) => el.hasAttribute("data-reactive-highlighted"))
2500
+ if (current < 0) return
2501
+ event.preventDefault()
2502
+ options[current].click()
2503
+ }
2504
+
2505
+ listnavClose(event) {
2506
+ for (const el of this.#listnavOptions(event)) el.removeAttribute("data-reactive-highlighted")
2507
+ }
2508
+
2509
+ // Move among THIS root's options: `pick(current, length)` returns the next
2510
+ // index (current is -1 when nothing is highlighted/focused). preventDefault
2511
+ // stops Arrow keys moving the caret in a search input, and the page scrolling
2512
+ // under a menu. Highlight mode writes data-reactive-highlighted; focus mode
2513
+ // (issue #271) focuses the chosen item, current being the item that is or
2514
+ // contains document.activeElement.
2515
+ #listnavMove(event, pick) {
2516
+ const options = this.#listnavOptions(event)
2517
+ if (!options.length) return
2518
+ event.preventDefault()
2519
+
2520
+ const focusMode = this.#listnavFocusMode(event)
2521
+ const active = globalThis.document?.activeElement
2522
+ const current = focusMode
2523
+ ? options.findIndex((el) => el === active || (active != null && el.contains?.(active)))
2524
+ : options.findIndex((el) => el.hasAttribute("data-reactive-highlighted"))
2525
+ const chosen = options[pick(current, options.length)]
2526
+
2527
+ if (focusMode) {
2528
+ chosen.focus?.()
2529
+ } else {
2530
+ for (const el of options) el.removeAttribute("data-reactive-highlighted")
2531
+ chosen.setAttribute("data-reactive-highlighted", "true")
2532
+ }
2533
+ chosen.scrollIntoView?.({ block: "nearest" })
2534
+ }
2535
+
2536
+ // Focus mode is read off the TRIGGER like the option selector (issue #271).
2537
+ #listnavFocusMode(event) {
2538
+ const trigger = event?.currentTarget ?? event?.target ?? this.element
2539
+ return trigger.getAttribute?.("data-reactive-listnav-focus-param") === "true"
2540
+ }
2541
+
2542
+ // The option elements this root owns (skips nested reactive roots, issue #15),
2543
+ // per the selector on data-reactive-listnav-option-param. The attr rides on the
2544
+ // TRIGGER element (the search input on(...) is spread onto), read from the
2545
+ // event; the options are still scoped to this controller's root. Empty when
2546
+ // unset. Falls back to the root for a directly-invoked call (unit tests). The
2547
+ // ownership predicate is hoisted ONCE per keypress (issue #117) — in the common
2548
+ // no-nested-root case it is a constant true, skipping a closest() walk per
2549
+ // option. Hidden options are excluded (issue #163): a reactive_filter (or any
2550
+ // `hidden` toggle) removes a row from the keyboard path too, so an Arrow can't
2551
+ // highlight — and Enter can't pick — an invisible option.
2552
+ #listnavOptions(event) {
2553
+ const trigger = event?.currentTarget ?? event?.target ?? this.element
2554
+ const selector =
2555
+ trigger.getAttribute?.("data-reactive-listnav-option-param") ??
2556
+ this.element.getAttribute("data-reactive-listnav-option-param")
2557
+ if (!selector) return []
2558
+ const owns = this.#ownershipFilter()
2559
+ return Array.from(this.element.querySelectorAll(selector)).filter((el) => !el.hidden && owns(el))
2560
+ }
2561
+
2562
+ // Enqueue the action — debounced if a debounce window is set, else immediately.
2563
+ // Split out of dispatch so both the no-confirm fast path and the post-confirm
2564
+ // microtask share one place (issue #55). `target` is captured up front because
2565
+ // this can run in a later microtask, after event.target has been reset.
2566
+ #proceed(target, action, params, debounce, throttle, optimistic, busy) {
2567
+ // Lifecycle veto point (issue #79): one cancelable reactive:before-dispatch
2568
+ // per user gesture — post-preventDefault, post-confirm, PRE-debounce (and
2569
+ // PRE-throttle, the same timing). event.preventDefault() skips the
2570
+ // debounce/throttle AND the enqueue entirely (nothing is scheduled).
2571
+ // retry() re-enters the queue directly, so this does NOT refire on a retry.
2572
+ // detail.params are the trigger's explicit params; sibling fields are
2573
+ // collected later, at send time.
2574
+ const before = this.#emit("reactive:before-dispatch", {
2575
+ action,
2576
+ params: this.#parseParams(params),
2577
+ element: this.element,
2578
+ }, { cancelable: true })
2579
+ if (before.defaultPrevented) return
2580
+
2581
+ // Debounced trigger (e.g. on(:update, event: "input", debounce: 300)):
2582
+ // coalesce rapid events into ONE round trip after a quiet period, instead of
2583
+ // one POST per keystroke (issue #17). A blur flushes a pending dispatch.
2584
+ // The optimistic hint (issue #98) and the busy state (issue #181) ride to
2585
+ // the flush too, so they apply ONCE per enqueue — a debounced input must not
2586
+ // flap toggle_class per keystroke, and its element must NOT be disabled
2587
+ // during the quiet period (that would break typing). Both apply at ENQUEUE.
2588
+ const ms = Number(debounce) || 0
2589
+ if (ms > 0) return this.#debounceDispatch(target, ms, action, params, optimistic, busy)
2590
+
2591
+ // Throttled trigger (e.g. on(:track, event: "scroll", window: true,
2592
+ // throttle: 250), issue #80): LEADING-EDGE rate limit — fire the first
2593
+ // event immediately, drop the rest until the window elapses. debounce and
2594
+ // throttle are mutually exclusive (the Ruby on() raises on both).
2595
+ const throttleMs = Number(throttle) || 0
2596
+ if (throttleMs > 0) return this.#throttleDispatch(target, throttleMs, action, params, optimistic, busy)
2597
+
2598
+ return this.#enqueue(action, params, optimistic, target, busy)
2599
+ }
2600
+
2601
+ // Apply the optimistic hint ONCE (recording its inverse) and chain the round
2602
+ // trip, threading that inverse onto THIS queued request so the serialized
2603
+ // per-controller queue reverts the RIGHT request's hint on failure (issue
2604
+ // #98). Applying here — the single flush/enqueue point every path funnels
2605
+ // through — is what makes a hint apply once per enqueue, not per raw dispatch.
2606
+ //
2607
+ // The busy state (issue #181) applies here too, for the same reason: enqueue
2608
+ // is the moment the request is committed to the queue, so the always-on busy
2609
+ // vocabulary (data-reactive-busy on the trigger + root, aria-busy via a pending
2610
+ // counter, busy_on scoping) and the busy hint (disable + class + text swap)
2611
+ // cover the WHOLE pending window — queue wait included — not just the fetch. It
2612
+ // returns a `settle` closure that #perform runs in its finally (success OR
2613
+ // failure), guarded so a morph-replaced trigger is never clobbered.
2614
+ #enqueue(action, params, optimistic, target, busy) {
2615
+ // The always-on busy state and the activity signal, now: they cover the
2616
+ // whole pending window, a wait for the hints module included. `pending`
2617
+ // is what this request's hint engine recorded — filled in by the apply
2618
+ // below, which may run a moment later than this call.
2619
+ const pending = { inverse: null, undo: [], resurrect: null }
2620
+ const settle = this.#applyBusy(action, target, pending)
2621
+ const wanted = Boolean(optimistic || busy)
2622
+ const apply = (hints) => {
2623
+ if (!wanted || !hints) return
2624
+ const core = this.#featureCore()
2625
+ pending.inverse = __SPLIT__
2626
+ ? hints.optimistic(this, core, optimistic, target)
2627
+ : hintsFeature.optimistic(this, core, optimistic, target)
2628
+ pending.undo.push(...(__SPLIT__ ? hints.busy(this, core, busy, target) : hintsFeature.busy(this, core, busy, target)))
2629
+ // Debug-only teaching aid (issue #181): if optimistic: { hide: true } is
2630
+ // used for instant-delete but the reply RE-RENDERS the element (bringing
2631
+ // it back), that hint was pointless — the developer likely wanted
2632
+ // reply.remove. Capture the hidden nodes now; the success path re-checks
2633
+ // the OBSERVED DOM after the morph (never inferred from the verb).
2634
+ if (this.#debugEnabled()) {
2635
+ pending.resurrect = __SPLIT__
2636
+ ? hints.resurrection(this, core, optimistic, target)
2637
+ : hintsFeature.resurrection(this, core, optimistic, target)
2638
+ }
2639
+ }
2640
+ // A hint applies ONCE per enqueue — the single flush point every path
2641
+ // funnels through — never per raw dispatch. With the module here (the
2642
+ // default client, always) that is now, synchronously.
2643
+ const loaded = __SPLIT__ ? featureModules.get("hints") : true
2644
+ if (!wanted || loaded) apply(loaded)
2645
+ // A feature still loading may be about to change what this request reads
2646
+ // (issue #275: the draft restore writes the fields #perform collects). Only
2647
+ // such a feature — one that `gates` — holds a request back, until it has
2648
+ // connected: at most the feature timeout, and on a root that never waited
2649
+ // for one, not at all. A feature that does not gate (the defer module)
2650
+ // never delays a request, however slow its import. A request WITH a hint
2651
+ // on a page that has not loaded the hints module waits for that import
2652
+ // the same way (at most the feature timeout), then applies the hint and
2653
+ // goes out — once.
2654
+ const perform = () => this.#perform(action, params, pending, settle)
2655
+ const ready = () => (this.#featureGate ? this.#featureGate.then(perform) : perform())
2656
+ this.queue = (this.queue ?? Promise.resolve()).then(() =>
2657
+ wanted && !loaded ? this.#awaitHints().then(apply).then(ready) : ready(),
2658
+ )
2659
+ return this.queue
2660
+ }
2661
+
2662
+ // The hints module, or null once its import failed or outlasted the feature
2663
+ // timeout (the request then goes out without its hint).
2664
+ #awaitHints() {
2665
+ return this.#awaitFeatureOrNull("hints")
2666
+ }
2667
+
2668
+ // Reset a per-element timer; only enqueue the round trip after `ms` of quiet.
2669
+ // Also flush immediately on blur so leaving the field never drops the last
2670
+ // edit (a long debounce shouldn't swallow a value the user tabbed away from).
2671
+ #debounceDispatch(target, ms, action, params, optimistic, busy) {
2672
+ this.#clearDebounce(target)
2673
+
2674
+ const flush = () => {
2675
+ this.#clearDebounce(target)
2676
+ this.#enqueue(action, params, optimistic, target, busy)
2677
+ }
2678
+ const timer = setTimeout(flush, ms)
2679
+ target?.addEventListener?.("blur", flush, { once: true })
2680
+ this.#debounceTimers.set(target, { timer, flush })
2681
+ }
2682
+
2683
+ #clearDebounce(target) {
2684
+ const pending = this.#debounceTimers.get(target)
2685
+ if (!pending) return
2686
+ clearTimeout(pending.timer)
2687
+ target?.removeEventListener?.("blur", pending.flush)
2688
+ this.#debounceTimers.delete(target)
2689
+ }
2690
+
2691
+ // Clear every pending debounce timer (used on disconnect). Reuses
2692
+ // #clearDebounce so all timer/listener teardown stays in one place. Snapshot
2693
+ // the keys first — #clearDebounce mutates the map as it goes.
2694
+ #clearAllDebounces() {
2695
+ for (const target of [...this.#debounceTimers.keys()]) this.#clearDebounce(target)
2696
+ }
2697
+
2698
+ // Leading-edge throttle (issue #80), mirroring #debounceDispatch: the FIRST
2699
+ // event fires immediately; a suppression timer then drops further events
2700
+ // until the window elapses (no trailing fire — dropped, not queued). Timers
2701
+ // are keyed on action + target, NOT target alone: window-bound scroll/resize
2702
+ // events all share event.target === document, so two window-bound triggers
2703
+ // on one component would otherwise collide on one timer.
2704
+ #throttleDispatch(target, ms, action, params, optimistic, busy) {
2705
+ const timers = this.#throttleTimers.get(target) ?? new Map()
2706
+ if (timers.has(action)) return // inside the window — suppress
2707
+
2708
+ const timer = setTimeout(() => {
2709
+ timers.delete(action)
2710
+ if (timers.size === 0) this.#throttleTimers.delete(target)
2711
+ }, ms)
2712
+ timers.set(action, timer)
2713
+ this.#throttleTimers.set(target, timers)
2714
+ return this.#enqueue(action, params, optimistic, target, busy) // leading edge: fire NOW
2715
+ }
2716
+
2717
+ // Clear every throttle suppression timer (used on disconnect, alongside
2718
+ // #clearAllDebounces) so nothing outlives the element.
2719
+ #clearAllThrottles() {
2720
+ for (const timers of this.#throttleTimers.values()) {
2721
+ for (const timer of timers.values()) clearTimeout(timer)
2722
+ }
2723
+ this.#throttleTimers.clear()
2724
+ }
2725
+
2726
+ // Raw-dispatch a lifecycle CustomEvent (issue #79). Deliberately NOT
2727
+ // Stimulus's this.dispatch() helper — that name is SHADOWED by this
2728
+ // controller's own dispatch(event) action method. Bubbling + composed so a
2729
+ // page-level listener (or `data-action="reactive:error->toast#show"` on an
2730
+ // ancestor) hears it. After a plain (non-morph) replace this.element is a
2731
+ // DETACHED node — a bubbling event on it never reaches document listeners —
2732
+ // so fall back to dispatching on document itself.
2733
+ #emit(name, detail, { cancelable = false } = {}) {
2734
+ const event = new CustomEvent(name, { bubbles: true, composed: true, cancelable, detail })
2735
+ const root = this.element.isConnected ? this.element : document
2736
+ root.dispatchEvent(event)
2737
+ return event
2738
+ }
2739
+
2740
+ // reactive:error detail: { action, params, kind, status?, body?, retry }.
2741
+ // `params` are the FULL params that were sent (collected fields + explicit
2742
+ // trigger params). retry() re-enters the request queue with the ORIGINAL raw
2743
+ // trigger params, so #perform re-reads the freshest token and RE-COLLECTS the
2744
+ // sibling fields — nothing stale is replayed. It does not refire
2745
+ // reactive:before-dispatch (one veto per user gesture), and it no-ops with a
2746
+ // warning once the root has left the DOM (retrying against a detached
2747
+ // element would post a stale token into nowhere).
2748
+ #emitError(action, rawParams, sentParams, extra) {
2749
+ const retry = () => {
2750
+ if (!this.element.isConnected) {
2751
+ console.warn("[phlex-reactive] retry() ignored — the reactive root left the DOM")
2752
+ return
2753
+ }
2754
+ return this.#enqueue(action, rawParams)
2755
+ }
2756
+ this.#emit("reactive:error", { action, params: sentParams, ...extra, retry })
2757
+ }
2758
+
2759
+ // Mark the reactive root as errored (issue #100) with the failure kind, so an
2760
+ // app can style it purely in CSS ([data-reactive-error] { … }) with zero JS.
2761
+ // Guarded — a plain replace may have detached the root before the failure lands.
2762
+ #markError(kind) {
2763
+ if (this.element?.isConnected === false) return
2764
+ this.element?.setAttribute?.("data-reactive-error", kind)
2765
+ }
2766
+
2767
+ // Clear the failure marker on the next successful apply (issue #100).
2768
+ #clearError() {
2769
+ this.element?.removeAttribute?.("data-reactive-error")
2770
+ }
2771
+
2772
+ // Offline fallback (issue #100): a network failure reached no server, so there
2773
+ // is no body to render. Clone the content of a server-rendered
2774
+ // <template data-reactive-error-flash> into the flash region so the user still
2775
+ // sees SOMETHING. The template is app-authored (trusted) — this is a pure
2776
+ // deep clone, never client templating of untrusted data. No template, or no
2777
+ // flash container, is a silent no-op (a page without the opt-in is unchanged).
2778
+ #renderNetworkFallback() {
2779
+ const template = document.querySelector("[data-reactive-error-flash]")
2780
+ if (!template?.content) return
2781
+ // The flash region is the host-app container Response#flash targets. Its id
2782
+ // defaults to "flash" (Phlex::Reactive.flash_target); an app that customized
2783
+ // it can point the fallback at the same node by putting the template's
2784
+ // data-reactive-error-flash value there — but the common case is #flash.
2785
+ const targetId = template.getAttribute("data-reactive-error-flash") || "flash"
2786
+ const region = document.getElementById(targetId)
2787
+ if (!region) return
2788
+ region.appendChild(template.content.cloneNode(true))
2789
+ }
2790
+
2791
+ // Latency simulator (issue #102): the delay itself is the dev feature's.
2792
+ // A request waits for the module only while a delay is stored (so the very
2793
+ // first delayed request is delayed too); with none stored this is two reads.
2794
+ #maybeSimulateLatency() {
2795
+ if (!__SPLIT__) return devtoolsFeature.delay()
2796
+ else {
2797
+ const devtools = featureModules.get("devtools")
2798
+ if (devtools) return devtools.delay()
2799
+ if (latencyStored()) return loadFeature("devtools").then((loaded) => loaded.delay(), () => {})
2800
+ }
2801
+ }
2802
+
2803
+ // Client debug mode (issue #108) — the "devtools-lite" lens. On when the Ruby
2804
+ // reactive_attrs stamped data-reactive-debug="true" (Phlex::Reactive.debug).
2805
+ // Read live (a single getAttribute) so the whole feature is inert for any app
2806
+ // that never opts in: OFF → this returns false and #perform builds no debug
2807
+ // object, parses no response, and logs nothing (the "zero cost when off"
2808
+ // invariant — one nil-check per dispatch). Guarded for a stub root with no
2809
+ // getAttribute (unit harnesses) so it degrades to off, never throwing.
2810
+ #debugEnabled() {
2811
+ return this.element?.getAttribute?.("data-reactive-debug") === "true"
2812
+ }
2813
+
2814
+ // A monotonic timestamp for the round-trip duration (ms). performance.now is
2815
+ // monotonic (immune to a wall-clock adjustment mid-request); Date.now is the
2816
+ // fallback for an exotic environment without it. ONLY called on the debug path,
2817
+ // so it costs nothing when debug is off.
2818
+ #debugNow() {
2819
+ return typeof performance !== "undefined" && typeof performance.now === "function"
2820
+ ? performance.now()
2821
+ : Date.now()
2822
+ }
2823
+
2824
+ async #perform(action, params, pending, settle) {
2825
+ // Auto-collect named field values inside this component so a button-
2826
+ // triggered action still receives sibling inputs (Livewire-style), plus any
2827
+ // chosen file inputs in the SAME walk. Explicit params
2828
+ // (data-reactive-params-param) win over collected fields.
2829
+ const { fields, files } = this.#collectFields()
2830
+ // Parse the explicit trigger params ONCE — reused below for allParams and, on
2831
+ // the debug path, for the params-only name list (so the trace can show
2832
+ // `params: [...]` distinct from the collected `+ collected: [...]`). #parseParams
2833
+ // is pure (a fresh object from a JSON string, or the same object by reference
2834
+ // when already parsed); allParams spreads a COPY and nothing mutates parsedParams
2835
+ // downstream (JSON.stringify / #buildFormData read allParams, a new object).
2836
+ const parsedParams = this.#parseParams(params)
2837
+ const allParams = { ...fields, ...parsedParams }
2838
+ const token = this.#currentToken
2839
+
2840
+ // File/multipart path (issue #34): if THIS root has a populated
2841
+ // <input type="file">, the action can't be JSON (JSON.stringify drops the
2842
+ // File). Send FormData instead — token + act + scalar params as fields, each
2843
+ // chosen file appended. The morph/token machinery downstream is identical;
2844
+ // only the request ENCODING differs when files are present.
2845
+ const multipart = files.length > 0
2846
+ const body = multipart
2847
+ ? this.#buildFormData(token, action, allParams, files)
2848
+ : JSON.stringify({ token, act: action, params: allParams })
2849
+
2850
+ // Client debug mode (issue #108): build the trace object ONLY when debug is on
2851
+ // (one nil-check otherwise — zero cost off). It carries NAMES only: the
2852
+ // explicit trigger param names and the collected sibling field names, split so
2853
+ // the group shows `params: [...] + collected: [...]`. status/streams/
2854
+ // tokenRefreshed are filled in at the branch that knows them; the group is
2855
+ // emitted once in the finally so EVERY exit (success or any failure) logs.
2856
+ const debug = this.#debugEnabled()
2857
+ ? {
2858
+ action,
2859
+ paramNames: Object.keys(parsedParams),
2860
+ fieldNames: Object.keys(fields),
2861
+ encoding: multipart ? "multipart" : "json",
2862
+ status: null,
2863
+ streams: [],
2864
+ tokenRefreshed: false,
2865
+ started: this.#debugNow(),
2866
+ }
2867
+ : null
2868
+
2869
+ // aria-busy on the root is now driven by the loading pending counter
2870
+ // (#applyLoading, applied at ENQUEUE so it covers the queue wait too), not
2871
+ // set here. #settleLoading in the finally clears it — see #enqueue.
2872
+
2873
+ // Latency simulator (issue #102): the busy window (aria-busy + loading state)
2874
+ // is already applied at enqueue, so awaiting the configured delay HERE — after
2875
+ // that window opened and before the fetch — is what makes it visible on
2876
+ // localhost. A null/absent/non-positive sessionStorage key is a no-op (zero
2877
+ // production surface), so this line vanishes for any app that never opts in.
2878
+ await this.#maybeSimulateLatency()
2879
+
2880
+ try {
2881
+ // Offline gate (issue #101), authoritative at the NETWORK BOUNDARY (send
2882
+ // time), not at enqueue. A click can enqueue while online and reach here
2883
+ // after going offline (a debounced/queued request, a rapid transition) —
2884
+ // gating in #perform makes the kind consistently "offline" for that whole
2885
+ // condition instead of leaking through as a "network" fetch throw. The
2886
+ // fetch never fires, so the edit is not half-sent; the finally still runs
2887
+ // (settle clears loading), the optimistic hint reverts, and retry() (which
2888
+ // re-enters #perform) re-checks and sends once back online.
2889
+ if (navigator.onLine === false) {
2890
+ this.#revertOptimistic(pending.inverse)
2891
+ this.#markError("offline")
2892
+ this.#emitError(action, params, allParams, { kind: "offline" })
2893
+ return
2894
+ }
2895
+
2896
+ let response
2897
+ try {
2898
+ const headers = {
2899
+ Accept: "text/vnd.turbo-stream.html",
2900
+ "X-CSRF-Token": this.#csrfToken(),
2901
+ }
2902
+ // For JSON we declare the content type; for multipart we must NOT — the
2903
+ // browser sets `multipart/form-data; boundary=…` itself, and overriding it
2904
+ // would strip the boundary and corrupt the body server-side.
2905
+ if (!multipart) headers["Content-Type"] = "application/json"
2906
+ // Send the pgbus SSE connection id (if subscribed) so the server can
2907
+ // exclude this connection from its own broadcast echo — the actor
2908
+ // already gets the action's HTTP response. Harmless without pgbus.
2909
+ const connectionId = this.#connectionId()
2910
+ if (connectionId) headers["X-Pgbus-Connection"] = connectionId
2911
+
2912
+ // ONLY `fetch` itself is the network boundary — offline, DNS, a reset
2913
+ // connection. Everything below this inner try (reading the body,
2914
+ // extracting the token, handing streams to Turbo) runs AFTER the
2915
+ // server already processed the mutation, so none of it belongs in the
2916
+ // `kind: "network"` / retriable bucket (CodeRabbit review on #89): if
2917
+ // renderStreamMessage throws, retry()ing would re-POST an action the
2918
+ // server already completed — see the outer catch below. (NOT a
2919
+ // reactive:applied LISTENER throwing — per the DOM spec, dispatchEvent
2920
+ // never propagates a listener's exception back to its caller, so that
2921
+ // case can't reach this catch at all; verified in the JS test suite.)
2922
+ countReactiveRequest("action")
2923
+ response = await fetch(this.#actionPath(), {
2924
+ method: "POST",
2925
+ headers,
2926
+ body,
2927
+ credentials: "same-origin",
2928
+ // Bound the request (issue #101): a server that never answers used to
2929
+ // wedge this.queue forever (the finally that clears aria-busy/loading
2930
+ // never ran). AbortSignal.timeout(ms) aborts the fetch after the
2931
+ // configured window; the abort surfaces in this catch as a
2932
+ // DOMException named "TimeoutError" (see the branch below).
2933
+ signal: AbortSignal.timeout(this.#timeoutMs()),
2934
+ })
2935
+ } catch (error) {
2936
+ console.error("[phlex-reactive] action error", error)
2937
+ this.#revertOptimistic(pending.inverse)
2938
+ // AbortSignal.timeout() rejects with a DOMException named "TimeoutError"
2939
+ // (a manual AbortController.abort() would be "AbortError" — we don't use
2940
+ // one, but accept it too for robustness). A timeout is NOT "offline":
2941
+ // the request left and the server didn't answer in time; connectivity is
2942
+ // unknown. So do NOT clone the offline-fallback template or mark network
2943
+ // — just fire kind:"timeout" (retriable). The queue still advances
2944
+ // (#perform returns, never rejects), so the hung request un-wedges it.
2945
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
2946
+ this.#markError("timeout")
2947
+ this.#emitError(action, params, allParams, { kind: "timeout" })
2948
+ return
2949
+ }
2950
+ // No server reached — nothing to render (issue #100). Clone a
2951
+ // server-rendered <template data-reactive-error-flash> into the flash
2952
+ // region as an offline fallback. The template is rendered by the app
2953
+ // (trusted), so this is a pure clone — no client templating of data.
2954
+ this.#renderNetworkFallback()
2955
+ this.#markError("network")
2956
+ this.#emitError(action, params, allParams, { kind: "network" })
2957
+ return
2958
+ }
2959
+
2960
+ // Debug (issue #108): the server answered — record the status for the trace
2961
+ // now, so every response branch below (redirected/http/content-type/ok) logs
2962
+ // it. The transport-failure branches above return before here (they have no
2963
+ // status); their group still fires from the finally with status null → "—".
2964
+ if (debug) debug.status = response.status
2965
+
2966
+ if (response.redirected) {
2967
+ console.error("[phlex-reactive] action was redirected (auth/CSRF?) — no update applied")
2968
+ this.#revertOptimistic(pending.inverse)
2969
+ this.#markError("redirected")
2970
+ this.#emitError(action, params, allParams, { kind: "redirected", status: response.status })
2971
+ return
2972
+ }
2973
+ if (!response.ok) {
2974
+ const errorBody = await response.text()
2975
+ console.error(`[phlex-reactive] action failed: HTTP ${response.status}`, errorBody)
2976
+ this.#revertOptimistic(pending.inverse)
2977
+ // Render a non-OK turbo-stream body so a server-rendered error flash
2978
+ // (an error_flash rescue, or a status: :unprocessable_entity validation
2979
+ // reply from a plain controller) is actually SHOWN — instead of being
2980
+ // read only for the console (issue #100). #extractToken is run as usual;
2981
+ // it NO-OPS when no stream re-renders our id, so a 400 InvalidToken body
2982
+ // never refreshes the held identity token (which is not a nonce and stays
2983
+ // retry-valid — do not "fix" that). A non-turbo-stream body is left to the
2984
+ // console.error above (an HTML error page must not be handed to Turbo).
2985
+ if ((response.headers.get("Content-Type") || "").includes("turbo-stream")) {
2986
+ const fresh = this.#extractToken(errorBody)
2987
+ this.#currentToken = fresh ?? this.#currentToken
2988
+ if (debug) {
2989
+ if (__SPLIT__) this.#devtools((devtools) => devtools.recordBody(debug, errorBody, fresh))
2990
+ else devtoolsFeature.recordBody(debug, errorBody, fresh)
2991
+ }
2992
+ window.Turbo.renderStreamMessage(errorBody)
2993
+ }
2994
+ this.#markError("http")
2995
+ this.#emitError(action, params, allParams, { kind: "http", status: response.status, body: errorBody })
2996
+ return
2997
+ }
2998
+
2999
+ const contentType = response.headers.get("Content-Type") || ""
3000
+ if (!contentType.includes("turbo-stream")) {
3001
+ console.error(`[phlex-reactive] expected a turbo-stream, got "${contentType}" — no update applied`)
3002
+ this.#revertOptimistic(pending.inverse)
3003
+ this.#markError("content-type")
3004
+ this.#emitError(action, params, allParams, { kind: "content-type", status: response.status })
3005
+ return
3006
+ }
3007
+
3008
+ const html = await response.text()
3009
+ // Capture the new token from the response synchronously, so the next
3010
+ // queued request uses it without waiting for the async DOM morph.
3011
+ const fresh = this.#extractToken(html)
3012
+ this.#currentToken = fresh ?? this.#currentToken
3013
+ // Debug (issue #108): record the stream actions/targets + whether a refresh
3014
+ // arrived, from the body we JUST read (reuse — no second text() read). Never
3015
+ // the token or template contents.
3016
+ if (debug) {
3017
+ if (__SPLIT__) this.#devtools((devtools) => devtools.recordBody(debug, html, fresh))
3018
+ else devtoolsFeature.recordBody(debug, html, fresh)
3019
+ }
3020
+ // Turbo applies the <turbo-stream> ops by id. A plain replace is an
3021
+ // outerHTML swap (focus on the replaced subtree is lost); a method="morph"
3022
+ // replace (Response.morph) or an update morphs in place, preserving the
3023
+ // focused input + caret on unchanged nodes — see issue #28.
3024
+ window.Turbo.renderStreamMessage(html)
3025
+ // Debug-only (issue #181): the morph may apply a microtask later, so check
3026
+ // the resurrected-hide case AFTER it lands. Off the debug path, resurrect is
3027
+ // null — zero cost.
3028
+ if (pending.resurrect) queueMicrotask(pending.resurrect)
3029
+ // A successful apply CLEARS any prior failure marker (issue #100), so
3030
+ // error-driven CSS on the root (a red border, a shake) resets on recovery.
3031
+ this.#clearError()
3032
+ // Lifecycle hook (issue #79): the streams were HANDED TO Turbo — a
3033
+ // renderStreamMessage applies asynchronously, so the DOM mutation may
3034
+ // complete a tick later. Apps needing post-morph timing listen to Turbo's
3035
+ // own events; this one is for "the action round trip succeeded".
3036
+ this.#emit("reactive:applied", { action, params: allParams, html })
3037
+ } catch (error) {
3038
+ // The server already processed this action successfully (we're past the
3039
+ // fetch) — a throw here is a CLIENT-side apply failure (a malformed
3040
+ // response, a broken Turbo render — NOT a reactive:applied listener
3041
+ // throw, which dispatchEvent never propagates here), not a transport
3042
+ // failure. kind: "apply" carries NO retry() — retrying would re-POST an
3043
+ // action the server already completed.
3044
+ console.error("[phlex-reactive] action error", error)
3045
+ this.#revertOptimistic(pending.inverse)
3046
+ this.#emit("reactive:error", { action, params: allParams, kind: "apply" })
3047
+ } finally {
3048
+ // Settle the loading state (issue #99): decrement the pending counter,
3049
+ // drop the trigger's/root's busy tokens, restore disabled/text/class —
3050
+ // guarded so a morph-replaced trigger is never clobbered. Runs on EVERY
3051
+ // exit (success, every failure branch, or an apply throw).
3052
+ settle?.()
3053
+ // Debug (issue #108): emit the group here so EVERY exit path logs exactly
3054
+ // once — success, any transport/response failure, or an apply throw. Null
3055
+ // when debug is off (zero cost). The round-trip ms is measured now, at the
3056
+ // finally, so it spans the whole #perform (fetch + apply) regardless of exit.
3057
+ if (debug) {
3058
+ if (__SPLIT__) this.#devtools((devtools) => devtools.trace(this, { ...debug, ms: this.#debugNow() - debug.started }))
3059
+ else devtoolsFeature.trace(this, { ...debug, ms: this.#debugNow() - debug.started })
3060
+ }
3061
+ }
3062
+ }
3063
+
3064
+ get #currentToken() {
3065
+ return this.#tokenCache ?? this.tokenValue
3066
+ }
3067
+
3068
+ set #currentToken(value) {
3069
+ this.#tokenCache = value
3070
+ }
3071
+
3072
+ // A token written into the DOM wins over the cached one (issue #301): an
3073
+ // outside morph (a broadcast, a page refresh, a dormant morph-back) rewrites
3074
+ // the attribute while this controller survives, and Stimulus calls this on
3075
+ // that change and on a reconnect after it. Our own reply's morph writes the
3076
+ // token #extractToken already cached, so dropping the cache changes nothing —
3077
+ // unless a later reply was already extracted: then the attribute holds the
3078
+ // older token until that reply's morph lands, the same frame (Turbo renders
3079
+ // streams after nextRepaint), before any queued dispatch reads it.
3080
+ tokenValueChanged() {
3081
+ this.#tokenCache = undefined
3082
+ }
3083
+
3084
+ // Read the next token for THIS controller — the one that re-renders THIS
3085
+ // element's id, never just the first token in the body (issue #46). On a
3086
+ // collection of REACTIVE rows the prepended/appended ROW carries its OWN
3087
+ // data-reactive-token-value and it sorts FIRST in the response; the list's own
3088
+ // fresh token rides a trailing `reactive:token` stream targeting the container.
3089
+ // Grabbing the first match stored the ROW's token, so the list's SECOND
3090
+ // dispatch sent a row token → failed verification → add-once-only. This mirrors
3091
+ // the server's carries_token_for? (#44): a stream carries OUR token only when it
3092
+ // RE-RENDERS our id (reactive:token / replace / update of `this.element.id`) —
3093
+ // append/prepend insert children and never count. Returns undefined when no
3094
+ // stream re-renders our id, so #currentToken keeps its existing value.
3095
+ #extractToken(html) {
3096
+ const id = this.element.id
3097
+ if (!id) {
3098
+ // No id to self-match (shouldn't happen for a reactive root). Fall back to
3099
+ // the legacy first-token behavior so a single-component response still works.
3100
+ return html.match(/data-reactive-token-value="([^"]+)"/)?.[1]
3101
+ }
3102
+
3103
+ const { token, self } = this.#tokenRegexes(id)
3104
+
3105
+ // The dedicated token-only refresh for THIS element (partial updates / the
3106
+ // collection container) — an attribute on the <turbo-stream> itself.
3107
+ const tokenStream = html.match(token)
3108
+ if (tokenStream) return tokenStream[1]
3109
+
3110
+ // A full self re-render: a replace/update of THIS element whose template root
3111
+ // carries the fresh token. Scope the token search to that one stream so a
3112
+ // sibling/child token elsewhere in the body can't leak in.
3113
+ const selfStream = html.match(self)
3114
+ if (selfStream) return selfStream[1].match(/data-reactive-token-value="([^"]+)"/)?.[1]
3115
+
3116
+ // Nothing re-rendered our id — keep the current token.
3117
+ return undefined
3118
+ }
3119
+
3120
+ // The two per-id RegExps #extractToken uses to self-match this element's next
3121
+ // token (issue #118). `this.element.id` is page-stable, so these are compiled
3122
+ // ONCE and reused across every response — instead of allocating two fresh
3123
+ // RegExps per round trip. The memo is KEYED ON THE ID and rebuilt when it
3124
+ // changes: a re-render that re-identifies the root must scan for the NEW target,
3125
+ // never the stale one (or the token would freeze — see the id-change bun test).
3126
+ // The PATTERNS are byte-identical to the pre-memo inline literals; only their
3127
+ // allocation moved.
3128
+ #tokenRegexes(id) {
3129
+ const cache = this.#tokenRegexCache
3130
+ if (cache && cache.id === id) return cache
3131
+ const escaped = escapeRegExp(id)
3132
+ return (this.#tokenRegexCache = {
3133
+ id,
3134
+ token: new RegExp(
3135
+ `<turbo-stream\\b[^>]*\\baction="reactive:token"[^>]*\\btarget="${escaped}"[^>]*\\bdata-reactive-token-value="([^"]+)"`,
3136
+ ),
3137
+ self: new RegExp(
3138
+ `<turbo-stream\\b[^>]*\\baction="(?:replace|update)"[^>]*\\btarget="${escaped}"[^>]*>([\\s\\S]*?)</turbo-stream>`,
3139
+ ),
3140
+ })
3141
+ }
3142
+
3143
+ // True when `el` is collected by THIS reactive root and not by a nested one.
3144
+ // A reactive component can be rendered inside another (both are
3145
+ // data-controller="reactive" roots). querySelectorAll() descends into nested
3146
+ // roots, so without this guard an outer action would sweep the inner roots'
3147
+ // inputs into its own params (issue #15). An element belongs to this root iff
3148
+ // its nearest [data-controller~="reactive"] ancestor is this.element.
3149
+ #ownsField(el) {
3150
+ return el.closest('[data-controller~="reactive"]') === this.element
3151
+ }
3152
+
3153
+ // A per-op ownership PREDICATE — the issue #117 fast path over issue #15
3154
+ // scoping. #ownsField answers "is this element mine, not a nested reactive
3155
+ // root's" with a per-element closest() walk; on a wide form or every keystroke
3156
+ // that walk runs per matched field. This hoists the DECISION to once per op.
3157
+ //
3158
+ // HYBRID GATE — closest() stays the source of truth; the nested-root query only
3159
+ // decides whether the fast path is SAFE:
3160
+ // * Fast path (overwhelmingly common): this root contains NO nested reactive
3161
+ // roots, so every element the caller's querySelectorAll returned is already
3162
+ // a direct descendant of this.element with no intervening reactive root —
3163
+ // it is ours. Return a constant-true predicate and skip the per-field
3164
+ // closest() walk entirely. That is the whole win.
3165
+ // * Nested case: fall back to the UNCHANGED #ownsField closest() check, so
3166
+ // scoping is byte-identical to before. We deliberately do NOT use
3167
+ // contains() here — the closest() form needs no node to implement
3168
+ // contains(), and on a real DOM the two agree for a descendant of
3169
+ // this.element (el.closest('[data-controller~="reactive"]') === this.element
3170
+ // iff no nested reactive-root descendant contains el).
3171
+ //
3172
+ // Computed ONCE per dispatch-scoped op (per #collectFields call, per recompute,
3173
+ // per #listnavOptions) and NEVER stored on the instance — a morph replaces
3174
+ // nodes, so a cached predicate would close over stale roots.
3175
+ #ownershipFilter() {
3176
+ const nested = this.element.querySelectorAll('[data-controller~="reactive"]')
3177
+ if (nested.length === 0) return () => true
3178
+ return (el) => this.#ownsField(el)
3179
+ }
3180
+
3181
+ // One walk over THIS root's named controls (not a nested reactive root's),
3182
+ // returning both the scalar `fields` and any chosen `files`. The ownership
3183
+ // predicate is hoisted ONCE (issue #117) via #ownershipFilter — in the common
3184
+ // no-nested-root case it is a constant true, so we skip a closest() walk per
3185
+ // field. A file input's `.value` is the useless "C:\fakepath\…" string, never a
3186
+ // scalar — so its chosen files are collected separately (honoring `multiple`)
3187
+ // and it adds no phantom blank value (issue #34). An empty `files` keeps the
3188
+ // JSON path.
3189
+ #collectFields() {
3190
+ const fields = {}
3191
+ const files = []
3192
+ const owns = this.#ownershipFilter() // compute ONCE per dispatch (issue #117)
3193
+ const controls = []
3194
+ this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
3195
+ if (!owns(field)) return
3196
+ if (field.type === "file") {
3197
+ // Carry the input's `multiple` flag so #buildFormData keeps the array
3198
+ // shape (params[name][]) even when the user picked exactly one file —
3199
+ // otherwise a [:file] schema would see a lone scalar upload and drop it.
3200
+ for (const file of field.files ?? []) files.push({ name: field.name, file, multiple: field.multiple })
3201
+ } else {
3202
+ // Held for a second pass: a hidden input is only a companion if a
3203
+ // checkbox somewhere in the root shares its name, which the first
3204
+ // occurrence cannot know yet.
3205
+ controls.push(field)
3206
+ }
3207
+ })
3208
+ // Collected before the first pass so the editor DOM is walked once.
3209
+ const editors = []
3210
+ this.element.querySelectorAll(`[name]${EDITOR_SELECTOR}`).forEach((el) => {
3211
+ if (owns(el)) editors.push(el) // the SAME hoisted predicate (nested reactive root, issue #15)
3212
+ })
3213
+ const arrayNames = this.#arrayFieldNames(controls)
3214
+ const companionNames = this.#companionNames(controls)
3215
+ for (const field of controls) {
3216
+ if (arrayNames.has(field.name)) {
3217
+ const slot = fields[field.name] ?? (fields[field.name] = [])
3218
+ if (field.type === "checkbox" || field.type === "radio") {
3219
+ // An unchecked box contributes NOTHING, the way a native submission
3220
+ // leaves it out. The group's value is the list of checked values, and
3221
+ // with none checked that list stays an EMPTY ARRAY rather than
3222
+ // vanishing: the action can tell "the operator cleared them" from
3223
+ // "the group never rendered", and an [:string] schema coerces [] to
3224
+ // []. A form body cannot carry the empty array, so #buildFormData
3225
+ // announces the group there instead.
3226
+ if (field.checked) slot.push(field.value)
3227
+ } else if (field.type === "hidden") {
3228
+ if (!companionNames.has(field.name)) slot.push(field.value)
3229
+ } else if (field.multiple && field.options) {
3230
+ for (const option of field.options) if (option.selected) slot.push(option.value)
3231
+ } else {
3232
+ slot.push(field.value)
3233
+ }
3234
+ } else if (field.type === "checkbox") {
3235
+ fields[field.name] = field.checked
3236
+ } else if (field.type === "radio") {
3237
+ if (field.checked) fields[field.name] = field.value
3238
+ } else {
3239
+ fields[field.name] = field.value
3240
+ }
3241
+ }
3242
+ // Named rich-text / custom editors (lexxy-editor, trix-editor) and bare
3243
+ // [contenteditable]. These aren't input/select/textarea, so the query above
3244
+ // skips them — without this, a reactive save posts an empty value and
3245
+ // silently wipes the field (issue #8). Read whatever the element exposes:
3246
+ // a custom editor's serialized `.value`, else its contenteditable text.
3247
+ // Under a plain name: only fill what the standard controls left absent or
3248
+ // empty, so a synced hidden input (e.g. Trix mirrors into one) still wins
3249
+ // when populated. Under a `[]` name the editor APPENDS to the group slot
3250
+ // instead, and a same-named hidden keeps its own say: nothing here can
3251
+ // tell a hidden that mirrors this editor from one that is a list JS
3252
+ // maintains.
3253
+ editors
3254
+ .forEach((el) => {
3255
+ // A plain element (e.g. a <div contenteditable>) has no `name` IDL
3256
+ // property — only the attribute — so read getAttribute, not el.name.
3257
+ const name = el.getAttribute("name")
3258
+ if (!name) return
3259
+ const own = el.value ?? el.textContent ?? el.innerHTML ?? ""
3260
+ // A `[]` name is a group here too: the editor APPENDS its value
3261
+ // instead of replacing the slot. Assigning a scalar posted
3262
+ // `{"notes[]": "<p>x</p>"}` while the draft snapshot pushed the same
3263
+ // control into an array — the wire and the draft disagreeing about one
3264
+ // field, and a declared array type seeing a string. A same-named hidden
3265
+ // is NOT read as this editor's twin: nothing here can tell a mirror
3266
+ // from a list JS maintains, and a value posted twice is visible while a
3267
+ // suppressed one is not.
3268
+ const existing = fields[name]
3269
+ // An editor that has not upgraded yet contributes NOTHING to a group.
3270
+ // Trix defines its elements in a setTimeout after load, and its "" is
3271
+ // not an empty value but an absent one: persistSnapshot omits such an
3272
+ // editor for the same reason, so this keeps the wire and the draft
3273
+ // saying the same thing.
3274
+ if (String(name).endsWith("[]") && !collectorEditorReady(el)) return
3275
+ if (String(name).endsWith("[]") && (existing === undefined || Array.isArray(existing))) {
3276
+ const slot = Array.isArray(existing) ? existing : (fields[name] = [])
3277
+ slot.push(own)
3278
+ return
3279
+ }
3280
+ // A scalar already under a `[]` name can only be a RADIO's: a radio
3281
+ // means "pick one" and keeps its single value with or without the
3282
+ // suffix, which is why #arrayFieldNames excepts it. Converting that to
3283
+ // a group here would discard the chosen value — measured, the post lost
3284
+ // it — so the editor stands down and the rule below applies, which
3285
+ // never overwrites a populated name.
3286
+ // Only fill what the standard controls left absent or empty, so a
3287
+ // synced hidden input still wins when populated.
3288
+ if (existing == null || existing === "") {
3289
+ fields[name] = own
3290
+ }
3291
+ })
3292
+ return { fields, files }
3293
+ }
3294
+
3295
+ // Names collected as an ARRAY rather than a single value: a name carrying the
3296
+ // `[]` suffix, the HTML convention for a group. That suffix is the ONLY
3297
+ // trigger — a group says so, it is not inferred from two controls happening
3298
+ // to share a name. Radios are excluded BY DESIGN: a radio group shares one
3299
+ // name to mean "pick one", and it keeps posting the single checked value,
3300
+ // suffix or not.
3301
+ //
3302
+ // Issue #258: without this, `fields[name] = field.checked` wrote a boolean per
3303
+ // checkbox and same-named boxes overwrote each other, so three boxes with two
3304
+ // ticked left the browser as the LAST box's checked state — the chosen values
3305
+ // never reached the wire, whatever the action's schema declared.
3306
+ #arrayFieldNames(controls) {
3307
+ const names = new Set()
3308
+ for (const field of controls) {
3309
+ // A radio group shares one name BY DESIGN to mean "pick one" and keeps
3310
+ // its single checked value, `[]` suffix or not.
3311
+ if (field.type === "radio") continue
3312
+ if (String(field.name).endsWith("[]")) names.add(field.name)
3313
+ }
3314
+ return names
3315
+ }
3316
+
3317
+ // Names whose group carries a hidden COMPANION: a hidden input is Rails' way
3318
+ // of giving a checkbox a value for the unchecked case, and it is never a
3319
+ // chosen value. Measured from the helpers, the three shapes are:
3320
+ //
3321
+ // check_box(:u, :sub)
3322
+ // <input name="u[sub]" type="hidden" value="0"><input type="checkbox" value="1" name="u[sub]">
3323
+ // check_box(:u, :ids, {multiple: true}, "3")
3324
+ // <input name="u[ids][]" type="hidden" value="0"><input type="checkbox" value="3" name="u[ids][]">
3325
+ // collection_check_boxes(...) / an unchecked_value of nil
3326
+ // <input type="hidden" name="u[ids][]" value=""> — or no hidden at all
3327
+ //
3328
+ // The value differs (unchecked_value, blank, absent), so the value cannot be
3329
+ // the test. What identifies a companion is that a checkbox shares its name.
3330
+ // A hidden WITHOUT a same-named checkbox is a list JS maintains, and its
3331
+ // value is a chosen value like any other.
3332
+ #companionNames(controls) {
3333
+ const names = new Set()
3334
+ for (const field of controls) if (field.type === "checkbox") names.add(field.name)
3335
+ return names
3336
+ }
3337
+
3338
+ // Build the multipart body (issue #34). `token`/`act` are flat fields the
3339
+ // endpoint reads from params[:token]/params[:act]; scalar params nest under
3340
+ // params[<key>] (Rails parses the bracket into params[:params]); each file is
3341
+ // appended under params[<name>] (single) — a second file with the same name
3342
+ // (a `multiple` picker, several inputs sharing a name) is sent as
3343
+ // params[<name>][] so Rails coerces it to an array for a [:file] schema.
3344
+ #buildFormData(token, action, params, files) {
3345
+ const fd = new FormData()
3346
+ fd.append("token", token)
3347
+ fd.append("act", action)
3348
+ const emptyGroups = []
3349
+ for (const [key, value] of Object.entries(params)) {
3350
+ // A `[]` name carrying an array is the group shape (issue #258): every
3351
+ // element goes to params[name][], which Rack parses as an array. The
3352
+ // indexed form #appendField writes for a plain array (params[name][0],
3353
+ // params[name][1]) arrives as a hash of index keys — ParamSchema's array
3354
+ // type normalizes that back, but only an array type does, so the two
3355
+ // bodies would stop coercing identically for the same fields.
3356
+ //
3357
+ // An EMPTY group cannot be an empty array in a form body, so it is
3358
+ // ANNOUNCED instead: its key stays absent from params and its name goes
3359
+ // into `empty_groups[]`, a field of its own beside token/act/params. A
3360
+ // blank entry was the obvious alternative and is ambiguous — Rails leaves
3361
+ // `[""]` to the caller, and a `[:date]` or `[:file]` element reads it as
3362
+ // "did not come in", so treating it as "cleared" would change what those
3363
+ // params mean. The field is additive: a server that ignores it behaves
3364
+ // exactly as it does today, and so does a client that never sends it.
3365
+ if (Array.isArray(value) && String(key).endsWith("[]")) {
3366
+ if (value.length === 0) {
3367
+ emptyGroups.push(String(key).slice(0, -2))
3368
+ } else {
3369
+ const wire = `${this.#wireKey(key)}[]`
3370
+ for (const element of value) fd.append(wire, String(element))
3371
+ }
3372
+ } else {
3373
+ this.#appendField(fd, this.#wireKey(key), value)
3374
+ }
3375
+ }
3376
+ for (const name of emptyGroups) fd.append("empty_groups[]", name)
3377
+ const multiNames = this.#multiFileNames(files)
3378
+ for (const { name, file, multiple } of files) {
3379
+ // params[name][] when the input is `multiple` (array shape even for one
3380
+ // file) OR the name repeats across inputs; otherwise a lone scalar file.
3381
+ const asArray = multiple || multiNames.has(name)
3382
+ const key = asArray ? `${this.#wireKey(name)}[]` : this.#wireKey(name)
3383
+ fd.append(key, file, file.name)
3384
+ }
3385
+ return fd
3386
+ }
3387
+
3388
+ // The multipart wire key for a collected field/file name: params[...] with
3389
+ // the name's OWN brackets expanded into nesting segments (issue #231). The
3390
+ // old verbatim wrap of a Rails-bracketed name — params[blog_post[summary]] —
3391
+ // is unparseable by Rack: a scalar arrived as {"blog_post[summary" => {"]" =>
3392
+ // value}} (data corruption written through `update!` with a 200) and a file
3393
+ // never reached its schema key (silent drop). Expanding blog_post[summary]
3394
+ // into params[blog_post][summary] mirrors the server's bracket_path exactly —
3395
+ // split at the first "[", then each non-empty bracket segment; an empty
3396
+ // trailing segment (tags[]) drops, matching how the JSON path's
3397
+ // expand_bracket_keys coerces the same name — so a multipart body and a JSON
3398
+ // body coerce identically for the same fields (the documented contract).
3399
+ // A flat name stays a single params[name] wrap, byte-identical to before.
3400
+ #wireKey(name) {
3401
+ const raw = String(name)
3402
+ const head = raw.indexOf("[")
3403
+ if (head === -1) return `params[${raw}]`
3404
+ const segments = [raw.slice(0, head), ...(raw.slice(head).match(/[^\[\]]+/g) ?? [])]
3405
+ return `params${segments.map((segment) => `[${segment}]`).join("")}`
3406
+ }
3407
+
3408
+ // Append a param leaf to FormData under its bracketed key. FormData carries
3409
+ // only strings, so a NON-scalar param (a nested object or an array) is
3410
+ // bracket-EXPANDED into params[key][sub] / params[key][index][...] fields —
3411
+ // the SAME Rails-form shape the server's expand_bracket_keys / array_values
3412
+ // already parse, so a JSON body and a multipart body coerce identically
3413
+ // (issue #39). Previously a non-scalar was JSON.stringify'd into one
3414
+ // params[key]='<json>' field, which the server received as an un-decodable
3415
+ // String leaf and DROPPED (nested hash -> {}, array -> key removed).
3416
+ //
3417
+ // Arrays use NUMERIC indices (params[key][0], params[key][1]) — required for
3418
+ // an array-of-hash so each element's sub-keys stay grouped and the server's
3419
+ // index-hash sort rebuilds order; params[key][] would collapse them. A scalar
3420
+ // (string/number/boolean) is one string field, mirroring the JSON wire shape
3421
+ // (the server's :boolean/:integer casts read "true"/"42"). null/undefined is
3422
+ // an empty field. An EMPTY array/object emits NOTHING — FormData can't carry
3423
+ // []/{}, so the key is omitted and the action's keyword default applies (this
3424
+ // differs from the JSON path, where an explicit [] coerces to an empty array).
3425
+ #appendField(fd, key, value) {
3426
+ if (value == null) {
3427
+ fd.append(key, "")
3428
+ } else if (Array.isArray(value)) {
3429
+ value.forEach((element, index) => this.#appendField(fd, `${key}[${index}]`, element))
3430
+ } else if (typeof value === "object") {
3431
+ for (const [subKey, subValue] of Object.entries(value)) {
3432
+ this.#appendField(fd, `${key}[${subKey}]`, subValue)
3433
+ }
3434
+ } else {
3435
+ fd.append(key, String(value))
3436
+ }
3437
+ }
3438
+
3439
+ // Names that appear more than once across the chosen files (a `multiple`
3440
+ // picker, or several file inputs sharing a name) — those go to params[name][]
3441
+ // so the server sees an array; a lone file stays params[name].
3442
+ #multiFileNames(files) {
3443
+ const counts = new Map()
3444
+ for (const { name } of files) counts.set(name, (counts.get(name) ?? 0) + 1)
3445
+ return new Set([...counts].filter(([, n]) => n > 1).map(([name]) => name))
3446
+ }
3447
+
3448
+ #parseParams(raw) {
3449
+ if (!raw) return {}
3450
+ try {
3451
+ return typeof raw === "string" ? JSON.parse(raw) : raw
3452
+ } catch {
3453
+ return {}
3454
+ }
3455
+ }
3456
+
3457
+ // Stimulus typecasts a JSON param to the parsed array already; a raw string
3458
+ // (hand-built attr, non-typecasting harness) is parsed here. Delegates to the
3459
+ // shared parseOps so the controller and the reactive:js stream action (issue
3460
+ // #97) treat a malformed ops attr identically (→ [], never a throw).
3461
+ #parseOps(raw) {
3462
+ return parseOps(raw)
3463
+ }
3464
+
3465
+ // Interpret a [[name, args], ...] op list against this root (issue #95),
3466
+ // scoping each op's targets to this controller's own root via #opTargets (the
3467
+ // nested-reactive-root ownership filter, issue #15). The whitelist + skip
3468
+ // logic lives in the shared applyOps so runOps and the reactive:js stream
3469
+ // action interpret the SAME vocabulary the SAME way (client-side default-deny).
3470
+ #applyOps(list) {
3471
+ applyOps(
3472
+ list,
3473
+ (args) => this.#opTargets(args),
3474
+ (name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
3475
+ )
3476
+ }
3477
+
3478
+ // Issue #237: the verbose gate for zero-target diagnostics — the
3479
+ // verbose_errors stamp (ON by default in dev/test) or full debug mode (a
3480
+ // debug user must never see less). Read live off the root like #debugEnabled.
3481
+ #verboseEnabled() {
3482
+ return this.element?.getAttribute?.("data-reactive-verbose") === "true" || this.#debugEnabled()
3483
+ }
3484
+
3485
+ // Issue #237: called when a selector-form target resolved to ZERO elements on
3486
+ // this root. Past the verbose gate the devtools feature (issue #275) does the
3487
+ // work: the DOM probes, the dedupe, the trap-specific hint.
3488
+ #diagnoseZeroTargets(label, args) {
3489
+ if (!this.#verboseEnabled()) return
3490
+ if (__SPLIT__) this.#devtools((devtools) => devtools.diagnose(this, label, args))
3491
+ else devtoolsFeature.diagnose(this, label, args)
3492
+ }
3493
+
3494
+ // Run `use` with the devtools feature: now when it is loaded (the default
3495
+ // client, always), else once it has arrived — a warning or a trace a tick
3496
+ // late is still a warning. Called only past a debug/verbose gate, so a page
3497
+ // with neither never asks for the module.
3498
+ #devtools(use) {
3499
+ if (__SPLIT__) withFeature("devtools", use)
3500
+ }
3501
+
3502
+ // Resolve an op's targets: "@root" is this element; a selector resolves
3503
+ // WITHIN this root and excludes nested reactive roots' subtrees (issue #15
3504
+ // semantics — the same nearest-root ownership check the field walk uses);
3505
+ // global: true opts a single op out to document-wide.
3506
+ #opTargets(args) {
3507
+ const to = args.to
3508
+ if (to === "@root") return [this.element]
3509
+ if (typeof to !== "string" || to === "") return []
3510
+ if (args.global) return [...document.querySelectorAll(to)]
3511
+ return [...this.element.querySelectorAll(to)].filter((el) => this.#ownsField(el))
3512
+ }
3513
+
3514
+ // Whether a click-bound checkbox/radio trigger should keep its NATIVE flip
3515
+ // (issue #98). `checked: :keep` means "let the control flip now": the
3516
+ // unconditional preventDefault is exactly what suppresses that flip until the
3517
+ // morph, so we skip it — but ONLY for a checkbox/radio (a bare toggle click
3518
+ // has no form-navigation default to lose). Any other element (a button) keeps
3519
+ // preventDefault so an in-form submit can't navigate.
3520
+ #keepsNativeToggle(optimistic, target) {
3521
+ if (optimistic?.checked !== "keep") return false
3522
+ const type = target?.type
3523
+ return type === "checkbox" || type === "radio"
3524
+ }
3525
+
3526
+ // Replay the recorded undo ops on failure (issue #98), guarded by isConnected:
3527
+ // a plain (non-morph) replace can detach this subtree before the failure lands,
3528
+ // and reverting a stale/detached node is pointless (it's gone) — so a
3529
+ // disconnected root skips the revert entirely. On success NOTHING calls this:
3530
+ // the server re-render overwrites the hint, or (reply.remove / streams-only)
3531
+ // the hint is deliberately left standing.
3532
+ #revertOptimistic(inverse) {
3533
+ if (!inverse) return
3534
+ if (!this.element.isConnected) return
3535
+ for (const undo of inverse) undo()
3536
+ }
3537
+
3538
+ // Apply the BUSY state for THIS enqueue (issue #181) and return a `settle`
3539
+ // closure that undoes exactly this enqueue's contribution when the round trip
3540
+ // finishes (success OR any failure). Everything is refcounted so overlapping
3541
+ // enqueues never clobber: A's settle can't clear busy while B is still pending.
3542
+ //
3543
+ // Two layers:
3544
+ // 1. The ALWAYS-ON busy vocabulary (fires for every action, no hint needed):
3545
+ // data-reactive-busy="<action>" on the trigger and the root (a
3546
+ // space-separated, per-action refcounted set), aria-busy on the root (a
3547
+ // pending counter), and data-reactive-busy on any busy_on element scoped
3548
+ // to this action. Apps style a spinner with pure CSS and zero Ruby.
3549
+ // 2. The busy HINT (only when busy: was declared): the SAME cosmetic op set
3550
+ // as optimistic: (class ops, hide/show, disable, text), applied by the
3551
+ // hints feature (features/hints.js) and reverted on SETTLE (not on
3552
+ // failure) — its undo ops land in `pending.undo`, which the settle
3553
+ // closure replays. They apply at ENQUEUE — never during a debounce quiet
3554
+ // period — so a debounced input is not disabled mid-typing.
3555
+ #applyBusy(action, trigger, pending) {
3556
+ this.#markBusy(action, trigger)
3557
+ // Global activity signal (issue #201): this enqueue is one in-flight reactive
3558
+ // operation. Entered HERE (at enqueue, via #applyBusy) so the document marker
3559
+ // covers the whole pending window — queue wait included — exactly like the
3560
+ // per-root busy vocabulary. Exited once in the settle closure below, which
3561
+ // #perform runs in its finally on EVERY exit path (success or any failure).
3562
+ enterReactiveActivity()
3563
+
3564
+ let settled = false
3565
+ return () => {
3566
+ if (settled) return // one settle per enqueue, even if called twice
3567
+ settled = true
3568
+ this.#unmarkBusy(action, trigger)
3569
+ exitReactiveActivity()
3570
+ // Busy reverts on SETTLE regardless of outcome, guarded per element.
3571
+ for (const op of pending.undo) op()
3572
+ }
3573
+ }
3574
+
3575
+ // Layer 1 — the always-on busy markers. Trigger + root carry the action token;
3576
+ // the root's counter drives aria-busy; busy_on elements scoped to this action
3577
+ // light up. Refcounts (#busyActions, #busyPending) so overlapping requests
3578
+ // don't clear each other.
3579
+ #markBusy(action, trigger) {
3580
+ this.#setBusyToken(trigger, action, +1)
3581
+ this.#setBusyToken(this.element, action, +1)
3582
+
3583
+ this.#busyActions.set(action, (this.#busyActions.get(action) ?? 0) + 1)
3584
+ if (this.#busyPending++ === 0) this.element.setAttribute("aria-busy", "true")
3585
+
3586
+ for (const el of this.#busyOnTargets(action)) this.#setBusyToken(el, action, +1)
3587
+ }
3588
+
3589
+ #unmarkBusy(action, trigger) {
3590
+ this.#setBusyToken(trigger, action, -1)
3591
+ this.#setBusyToken(this.element, action, -1)
3592
+
3593
+ const count = (this.#busyActions.get(action) ?? 1) - 1
3594
+ if (count <= 0) this.#busyActions.delete(action)
3595
+ else this.#busyActions.set(action, count)
3596
+
3597
+ if (--this.#busyPending <= 0) {
3598
+ this.#busyPending = 0
3599
+ this.element.removeAttribute("aria-busy")
3600
+ }
3601
+
3602
+ for (const el of this.#busyOnTargets(action)) this.#setBusyToken(el, action, -1)
3603
+ }
3604
+
3605
+ // Add (+1) or remove (-1) `action` from an element's space-separated
3606
+ // data-reactive-busy token set, refcounted PER ELEMENT+ACTION so two queued
3607
+ // requests of the same action on the same element don't drop the token early
3608
+ // (and two DIFFERENT actions both keep their token — the set never clobbers).
3609
+ // The attribute is removed only when the set empties. No-op on a nullish/
3610
+ // detached element (a morph may have replaced the trigger before settle).
3611
+ #setBusyToken(el, action, delta) {
3612
+ if (!el || typeof el.getAttribute !== "function") return
3613
+
3614
+ const counts = (this.#busyTokenCounts.get(el) ?? new Map())
3615
+ const next = (counts.get(action) ?? 0) + delta
3616
+ if (next <= 0) counts.delete(action)
3617
+ else counts.set(action, next)
3618
+
3619
+ if (counts.size === 0) {
3620
+ this.#busyTokenCounts.delete(el)
3621
+ el.removeAttribute("data-reactive-busy")
3622
+ return
3623
+ }
3624
+ this.#busyTokenCounts.set(el, counts)
3625
+ el.setAttribute("data-reactive-busy", [...counts.keys()].join(" "))
3626
+ }
3627
+
3628
+ // busy_on elements scoped to THIS action, owned by this root (not a nested
3629
+ // reactive root's, issue #15). data-reactive-busy-on="<action>" is the marker
3630
+ // busy_on(:action) emits.
3631
+ #busyOnTargets(action) {
3632
+ const nodes = this.element.querySelectorAll?.("[data-reactive-busy-on]") ?? []
3633
+ return [...nodes].filter(
3634
+ (el) => el.getAttribute("data-reactive-busy-on") === action && this.#ownsField(el),
3635
+ )
3636
+ }
3637
+
3638
+ // The action path comes from a <meta> tag that is fixed for the page's life,
3639
+ // so resolve it once per controller and cache it — avoids a querySelector on
3640
+ // every dispatch (this runs on the request hot path, once per click/keystroke
3641
+ // round trip). Cached on the instance, so a fresh connect() (after a Turbo
3642
+ // navigation swaps the element) re-reads it.
3643
+ #actionPath() {
3644
+ return (this.#actionPathCache ??=
3645
+ document.querySelector('meta[name="phlex-reactive-action-path"]')?.content ||
3646
+ "/reactive/actions")
3647
+ }
3648
+
3649
+ // The per-request timeout in ms (issue #101), from a page-stable
3650
+ // <meta name="phlex-reactive-timeout"> (default 30000). Cached per-controller
3651
+ // like the action path. Parsed defensively: a missing/blank/non-positive/NaN
3652
+ // value falls back to the default, so a typo'd meta can never disable the
3653
+ // timeout (which would reintroduce the wedged-queue bug) or set a zero/negative
3654
+ // window that aborts instantly.
3655
+ #timeoutMs() {
3656
+ if (this.#timeoutMsCache != null) return this.#timeoutMsCache
3657
+ const raw = document.querySelector('meta[name="phlex-reactive-timeout"]')?.content
3658
+ const ms = Number(raw)
3659
+ return (this.#timeoutMsCache = Number.isFinite(ms) && ms > 0 ? ms : 30000)
3660
+ }
3661
+
3662
+ // CSRF token and connection id are read LIVE (not cached) on purpose: Rails
3663
+ // can rotate the CSRF token, and the pgbus connection id changes on an SSE
3664
+ // reconnect — caching either would send a stale value. A single querySelector
3665
+ // per request is cheap next to the round trip itself.
3666
+ #csrfToken() {
3667
+ return document.querySelector('meta[name="csrf-token"]')?.content ?? ""
3668
+ }
3669
+
3670
+ // The pgbus SSE connection id, if the page is subscribed to a stream. pgbus
3671
+ // reflects it onto the <pgbus-stream-source connection-id="…"> element (and
3672
+ // apps may mirror it to <meta name="pgbus-connection-id">). Returns null
3673
+ // when not present (e.g. no pgbus, or no active subscription) — the header
3674
+ // is then simply omitted.
3675
+ #connectionId() {
3676
+ return (
3677
+ document.querySelector("pgbus-stream-source[connection-id]")?.getAttribute("connection-id") ||
3678
+ document.querySelector('meta[name="pgbus-connection-id"]')?.content ||
3679
+ null
3680
+ )
3681
+ }
3682
+ }
3683
+
3684
+ // Register with Turbo as soon as it is there. LAST in the file: registration
3685
+ // reads the feature table (the dev module is loaded from it), which must
3686
+ // exist by then.
3687
+ if (typeof window !== "undefined") {
3688
+ if (window.Turbo) registerReactiveActions()
3689
+ else document.addEventListener("turbo:load", registerReactiveActions, { once: true })
3690
+ }