phlex-reactive 0.9.2 → 0.9.4

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +213 -0
  3. data/README.md +333 -1
  4. data/app/controllers/phlex/reactive/actions_controller.rb +120 -10
  5. data/app/javascript/phlex/reactive/inspect.js +225 -0
  6. data/app/javascript/phlex/reactive/inspect.min.js +4 -0
  7. data/app/javascript/phlex/reactive/inspect.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/reactive_controller.js +651 -2
  9. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  10. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
  11. data/lib/generators/phlex/reactive/claude/USAGE +15 -0
  12. data/lib/generators/phlex/reactive/claude/claude_generator.rb +86 -0
  13. data/lib/generators/phlex/reactive/install/install_generator.rb +3 -0
  14. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +56 -0
  15. data/lib/phlex/reactive/authorization.rb +118 -0
  16. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +105 -0
  17. data/lib/phlex/reactive/component/dsl.rb +70 -0
  18. data/lib/phlex/reactive/component/helpers.rb +240 -0
  19. data/lib/phlex/reactive/component/identity.rb +10 -1
  20. data/lib/phlex/reactive/component/lazy.rb +79 -0
  21. data/lib/phlex/reactive/component/registry.rb +7 -1
  22. data/lib/phlex/reactive/component.rb +1 -0
  23. data/lib/phlex/reactive/defer.rb +363 -0
  24. data/lib/phlex/reactive/deferred_render_job.rb +116 -0
  25. data/lib/phlex/reactive/doctor.rb +79 -11
  26. data/lib/phlex/reactive/engine.rb +16 -2
  27. data/lib/phlex/reactive/inspector/report.rb +140 -0
  28. data/lib/phlex/reactive/inspector.rb +253 -0
  29. data/lib/phlex/reactive/log_subscriber.rb +10 -0
  30. data/lib/phlex/reactive/mcp/base_tool.rb +57 -0
  31. data/lib/phlex/reactive/mcp/runner.rb +24 -0
  32. data/lib/phlex/reactive/mcp/server.rb +47 -0
  33. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +43 -0
  34. data/lib/phlex/reactive/mcp/tools/components_tool.rb +39 -0
  35. data/lib/phlex/reactive/mcp/tools/config_tool.rb +74 -0
  36. data/lib/phlex/reactive/mcp/tools/doctor_tool.rb +36 -0
  37. data/lib/phlex/reactive/mcp/tools/find_tool.rb +66 -0
  38. data/lib/phlex/reactive/mcp.rb +58 -0
  39. data/lib/phlex/reactive/reply.rb +18 -0
  40. data/lib/phlex/reactive/response.rb +47 -2
  41. data/lib/phlex/reactive/streamable.rb +5 -1
  42. data/lib/phlex/reactive/version.rb +1 -1
  43. data/lib/phlex/reactive.rb +253 -3
  44. data/lib/tasks/phlex_reactive.rake +28 -0
  45. metadata +22 -1
@@ -107,6 +107,302 @@ export function registerReactiveJs() {
107
107
  }
108
108
  }
109
109
 
110
+ // --- Deferred reply segments (issue #165) ----------------------------------
111
+ // The client half of reply.defer: the server's reply carries a
112
+ // `<turbo-stream action="reactive:defer" target="<id>">` directive and the
113
+ // real render reaches the SAME actor later — via a parallel fetch (pull) or a
114
+ // pgbus one-shot stream (push). Everything here is MODULE-level, deliberately
115
+ // OFF the per-controller request queue: the whole point is that the expensive
116
+ // segment never blocks the actor's next action.
117
+ //
118
+ // Supersession is the correctness core: pendingDefers keys one in-flight
119
+ // delivery per target id. A newer directive for the same target aborts the
120
+ // older fetch (or removes the older stream source), and an arrival applies
121
+ // ONLY while its entry is still current — so a fast typist's debounced
122
+ // keystrokes can never paint stale totals over fresh ones.
123
+ const pendingDefers = new Map()
124
+
125
+ // Test seam: clear the module-level registry between unit tests. Also resets
126
+ // the one-shot settle-listener guard so a test's fresh document re-registers
127
+ // the turbo:before-stream-render settler.
128
+ export function resetReactiveDefers() {
129
+ pendingDefers.clear()
130
+ deferStreamSettleRegistered = false
131
+ }
132
+
133
+ // Test seam: the `via` of a target's pending defer entry (or undefined) — lets
134
+ // tests assert an entry was SETTLED (dropped) on arrival without exposing the
135
+ // Map. Not used by the runtime.
136
+ export function pendingDeferVia(targetId) {
137
+ return pendingDefers.get(targetId)?.via
138
+ }
139
+
140
+ let deferStreamSettleRegistered = false
141
+
142
+ export function registerReactiveDefer() {
143
+ const actions = window.Turbo?.StreamActions
144
+ if (!actions || actions["reactive:defer"]) return
145
+ actions["reactive:defer"] = function () {
146
+ const target = this.getAttribute("target")
147
+ if (!target) return
148
+ if (this.getAttribute("data-reactive-defer-via") === "stream") {
149
+ startStreamDefer(target, this)
150
+ return
151
+ }
152
+ const token = this.getAttribute("data-reactive-defer-token")
153
+ if (!token) return
154
+ startFetchDefer(target, token)
155
+ }
156
+
157
+ // Settle a STREAM-lane pendingDefers entry when its arrival lands: the job's
158
+ // broadcast is a turbo-stream that replaces the target (and removes the
159
+ // source). A document-level turbo:before-stream-render hook drops the Map
160
+ // entry for a stream target the moment a stream renders against it — so the
161
+ // entry never outlives the delivery (the fetch lane settles inline; the
162
+ // stream lane's arrival is a broadcast this controller doesn't await, so it
163
+ // needs this hook). Registered once; a no-op without document.
164
+ if (!deferStreamSettleRegistered && typeof document !== "undefined" && document.addEventListener) {
165
+ deferStreamSettleRegistered = true
166
+ document.addEventListener("turbo:before-stream-render", settleStreamDeferOnRender)
167
+ }
168
+ }
169
+
170
+ // Drop a stream-lane pendingDefers entry when a turbo-stream renders against
171
+ // its target id (the job's replace) OR removes its source element. Keyed by the
172
+ // stream's target so an unrelated stream never settles a defer. Pure Map
173
+ // cleanup — the DOM apply is Turbo's; this only releases our bookkeeping.
174
+ function settleStreamDeferOnRender(event) {
175
+ const streamEl = event.target
176
+ const target = streamEl?.getAttribute?.("target")
177
+ if (!target) return
178
+ // The arrival replaces #<target>; the source removal targets
179
+ // reactive-defer-src-<target>. Either signals the stream delivered.
180
+ const targetId = target.startsWith("reactive-defer-src-")
181
+ ? target.slice("reactive-defer-src-".length)
182
+ : target
183
+ const entry = pendingDefers.get(targetId)
184
+ if (entry?.via === "stream") pendingDefers.delete(targetId)
185
+ }
186
+
187
+ // The pull lane: mark the target pending and POST the signed defer token to
188
+ // the defer endpoint, in parallel with everything else the page is doing.
189
+ function startFetchDefer(targetId, token) {
190
+ const el = document.getElementById(targetId)
191
+ if (!el) {
192
+ console.warn(`[phlex-reactive] reactive:defer target #${targetId} is not on the page — skipped`)
193
+ return
194
+ }
195
+ supersedeDefer(targetId)
196
+ markDeferPending(el)
197
+ const entry = { via: "fetch", abort: new AbortController(), timedOut: false }
198
+ pendingDefers.set(targetId, entry)
199
+ performDeferFetch(targetId, entry, token)
200
+ }
201
+
202
+ // The push lane: subscribe a <pgbus-stream-source> to the server-signed
203
+ // one-shot stream. Arrival + teardown need no client logic — the job's
204
+ // broadcast carries the replace AND a remove of this source element (its
205
+ // disconnectedCallback closes the SSE connection). since-id=0 on a fresh key
206
+ // replays a broadcast that beat the subscription (the durable-lane guarantee).
207
+ function startStreamDefer(targetId, directive) {
208
+ const el = document.getElementById(targetId)
209
+ if (!el) {
210
+ console.warn(`[phlex-reactive] reactive:defer target #${targetId} is not on the page — skipped`)
211
+ return
212
+ }
213
+ const src = directive.getAttribute("data-reactive-defer-src")
214
+ if (!src) return
215
+ if (!globalThis.customElements?.get?.("pgbus-stream-source")) {
216
+ // The server chose push on server-side capability, but this page has no
217
+ // pgbus client. Degrade to the fetch lane using the fallback token the push
218
+ // directive carries — rather than dead-end the shimmer. No token (an app on
219
+ // a bespoke transport) is a loud no-op.
220
+ const fallbackToken = directive.getAttribute("data-reactive-defer-token")
221
+ if (fallbackToken) {
222
+ startFetchDefer(targetId, fallbackToken)
223
+ return
224
+ }
225
+ console.error(
226
+ "[phlex-reactive] reactive:defer via=stream but <pgbus-stream-source> is not registered " +
227
+ "and no fallback token was provided — is the pgbus client loaded on this page?",
228
+ )
229
+ return
230
+ }
231
+ supersedeDefer(targetId)
232
+ markDeferPending(el)
233
+ const source = document.createElement("pgbus-stream-source")
234
+ // Deterministic id: the JOB's broadcast removes it by this exact id — the
235
+ // subscription tears itself down with the payload it delivered.
236
+ source.id = deferSourceId(targetId)
237
+ source.setAttribute("src", src)
238
+ source.setAttribute("since-id", directive.getAttribute("data-reactive-defer-since-id") ?? "0")
239
+ source.setAttribute("hidden", "")
240
+ document.body.appendChild(source)
241
+ // Record only { via } — NOT a strong ref to the source element. The job's
242
+ // broadcast removes the source by its deterministic id (its
243
+ // disconnectedCallback closes the SSE), so holding srcEl here would pin the
244
+ // detached node in this module-level Map forever (a leak). Supersession
245
+ // re-finds the element by id instead. The entry is dropped on
246
+ // supersession or when the arriving broadcast replaces the target (a
247
+ // turbo:before-stream-render hook, below).
248
+ pendingDefers.set(targetId, { via: "stream" })
249
+ }
250
+
251
+ // The deterministic id of a target's one-shot <pgbus-stream-source>.
252
+ function deferSourceId(targetId) {
253
+ return `reactive-defer-src-${targetId}`
254
+ }
255
+
256
+ async function performDeferFetch(targetId, entry, token) {
257
+ // Bound the wait like the action fetch (issue #101) — a hung defer must not
258
+ // shimmer forever. A manual timer (not AbortSignal.timeout) so the catch can
259
+ // tell a TIMEOUT (fail loudly) from a SUPERSEDED abort (stay silent).
260
+ const timer = setTimeout(() => {
261
+ entry.timedOut = true
262
+ entry.abort.abort()
263
+ }, deferTimeoutMs())
264
+
265
+ // The timeout is cleared ONLY after the body is fully read (below), not the
266
+ // moment headers arrive — a server that streams headers then stalls the body
267
+ // must still abort, or the shimmer hangs forever (the abort signal covers the
268
+ // whole fetch + body read, mirroring #perform's AbortSignal.timeout).
269
+ let response
270
+ try {
271
+ response = await fetch(deferPath(), {
272
+ method: "POST",
273
+ headers: {
274
+ Accept: "text/vnd.turbo-stream.html",
275
+ "Content-Type": "application/json",
276
+ "X-CSRF-Token": deferCsrfToken(),
277
+ },
278
+ body: JSON.stringify({ token }),
279
+ credentials: "same-origin",
280
+ signal: entry.abort.signal,
281
+ })
282
+ } catch (error) {
283
+ clearTimeout(timer)
284
+ if (pendingDefers.get(targetId) !== entry) return // superseded — silent
285
+ console.error("[phlex-reactive] deferred render failed", error)
286
+ failDefer(targetId, token)
287
+ return
288
+ }
289
+ if (pendingDefers.get(targetId) !== entry) {
290
+ clearTimeout(timer)
291
+ return // superseded mid-flight
292
+ }
293
+
294
+ if (response.status === 204) {
295
+ clearTimeout(timer)
296
+ // render? false — keep the current content, just clear the pending state.
297
+ settleDefer(targetId)
298
+ return
299
+ }
300
+ if (!response.ok) {
301
+ clearTimeout(timer)
302
+ console.error(`[phlex-reactive] deferred render failed: HTTP ${response.status}`)
303
+ failDefer(targetId, token, response.status)
304
+ return
305
+ }
306
+
307
+ let html
308
+ try {
309
+ html = await response.text()
310
+ } catch (error) {
311
+ clearTimeout(timer)
312
+ if (pendingDefers.get(targetId) !== entry) return
313
+ console.error("[phlex-reactive] deferred render failed reading the body", error)
314
+ failDefer(targetId, token)
315
+ return
316
+ }
317
+ clearTimeout(timer)
318
+ if (pendingDefers.get(targetId) !== entry) return // superseded during read
319
+
320
+ settleDefer(targetId)
321
+ // A normal replace/morph of the target — the fresh root carries no pending
322
+ // markers and a fresh action token, so the component lands interactive.
323
+ window.Turbo.renderStreamMessage(html)
324
+ }
325
+
326
+ // Abort/unsubscribe whatever delivery is in flight for this target. The
327
+ // deleted entry makes every late arrival fail its identity check — stale
328
+ // content can never paint.
329
+ function supersedeDefer(targetId) {
330
+ const existing = pendingDefers.get(targetId)
331
+ if (!existing) return
332
+ pendingDefers.delete(targetId)
333
+ if (existing.via === "fetch") existing.abort.abort()
334
+ // Stream lane: re-find the old source by its deterministic id and remove it
335
+ // (unsubscribe) — we deliberately don't hold a strong ref to the detached
336
+ // node. Its disconnectedCallback closes the SSE.
337
+ else document.getElementById(deferSourceId(targetId))?.remove?.()
338
+ }
339
+
340
+ function markDeferPending(el) {
341
+ el.setAttribute("data-reactive-defer-pending", "true")
342
+ el.setAttribute("aria-busy", "true")
343
+ }
344
+
345
+ function clearDeferPending(el) {
346
+ el.removeAttribute("data-reactive-defer-pending")
347
+ el.removeAttribute("aria-busy")
348
+ }
349
+
350
+ // Success/204: drop the registry entry, clear pending, and clear any prior
351
+ // defer failure marker (recovery resets error-driven CSS, issue #100 style).
352
+ function settleDefer(targetId) {
353
+ pendingDefers.delete(targetId)
354
+ const el = document.getElementById(targetId)
355
+ if (!el) return
356
+ clearDeferPending(el)
357
+ el.removeAttribute("data-reactive-error")
358
+ }
359
+
360
+ // Failure: clear pending (the shimmer must not lie), mark the root
361
+ // (data-reactive-error="defer" — style it in pure CSS), and emit a bubbling
362
+ // reactive:error whose retry() re-enters the defer fetch with the SAME token
363
+ // (still valid inside the TTL; an expired token 400s into this same path).
364
+ function failDefer(targetId, token, status) {
365
+ pendingDefers.delete(targetId)
366
+ const el = document.getElementById(targetId)
367
+ if (!el) return
368
+ clearDeferPending(el)
369
+ el.setAttribute("data-reactive-error", "defer")
370
+ const retry = () => {
371
+ const fresh = document.getElementById(targetId)
372
+ if (!fresh) {
373
+ console.warn("[phlex-reactive] defer retry() ignored — the target left the DOM")
374
+ return
375
+ }
376
+ fresh.removeAttribute("data-reactive-error")
377
+ startFetchDefer(targetId, token)
378
+ }
379
+ el.dispatchEvent(
380
+ new CustomEvent("reactive:error", {
381
+ bubbles: true,
382
+ composed: true,
383
+ detail: { kind: "defer", target: targetId, status, retry },
384
+ }),
385
+ )
386
+ }
387
+
388
+ function deferPath() {
389
+ return document.querySelector('meta[name="phlex-reactive-defer-path"]')?.content || "/reactive/defer"
390
+ }
391
+
392
+ // CSRF is read LIVE per request (Rails can rotate it) — same contract as the
393
+ // controller's #csrfToken.
394
+ function deferCsrfToken() {
395
+ return document.querySelector('meta[name="csrf-token"]')?.content ?? ""
396
+ }
397
+
398
+ // Same page-stable meta + default as the controller's #timeoutMs (issue #101),
399
+ // parsed defensively so a typo'd meta can never disable the bound.
400
+ function deferTimeoutMs() {
401
+ const raw = document.querySelector('meta[name="phlex-reactive-timeout"]')?.content
402
+ const ms = Number(raw)
403
+ return Number.isFinite(ms) && ms > 0 ? ms : 30000
404
+ }
405
+
110
406
  // Document-level self-dismissing flashes (issue #100). A flash rendered with
111
407
  // dismiss_after: carries data-reactive-dismiss-after="<ms>"; after the timeout
112
408
  // it removes itself. This is deliberately NOT a Stimulus controller — the flash
@@ -265,6 +561,7 @@ export function registerReactiveActions() {
265
561
  registerReactiveVisit()
266
562
  registerReactiveToken()
267
563
  registerReactiveJs()
564
+ registerReactiveDefer()
268
565
  registerReactiveDismiss()
269
566
  registerReactiveOffline()
270
567
  attachLatencyHandle()
@@ -443,6 +740,62 @@ function guardMirrorSelector(selector) {
443
740
  return false
444
741
  }
445
742
 
743
+ // Evaluate a show binding's declared literal predicate (issue #161) against
744
+ // the controlling field's current value. Exactly one of the three predicate
745
+ // attrs decides: equals (value === literal), not (value !== literal), in
746
+ // (value ∈ a JSON string list). The vocabulary is fixed and literal-only —
747
+ // never an expression, so there is no eval surface (the reactive_show helper
748
+ // enforces the same shape loudly at render; this is the client half of the
749
+ // two-sided posture). Returns true/false for a decidable binding, or null for
750
+ // a malformed/missing predicate — the caller SKIPS a null so a hand-built or
751
+ // stale binding never flips visibility it doesn't understand (default-deny,
752
+ // like the op whitelist).
753
+ function showBindingMatches(el, value) {
754
+ const equals = el.getAttribute("data-reactive-show-equals")
755
+ if (equals !== null) return value === equals
756
+ const not = el.getAttribute("data-reactive-show-not")
757
+ if (not !== null) return value !== not
758
+ const inRaw = el.getAttribute("data-reactive-show-in")
759
+ if (inRaw !== null) {
760
+ try {
761
+ const list = JSON.parse(inRaw)
762
+ if (Array.isArray(list)) return list.includes(value)
763
+ } catch {
764
+ // fall through to the warn below — malformed JSON and a non-array both skip
765
+ }
766
+ console.warn(`[phlex-reactive] malformed reactive_show in: list ${JSON.stringify(inRaw)} — skipped`)
767
+ return null
768
+ }
769
+ console.warn("[phlex-reactive] a reactive_show binding declares no predicate — skipped")
770
+ return null
771
+ }
772
+
773
+ // Evaluate an ALREADY-PARSED show predicate object (issue #164) — the
774
+ // reactive_show_targets map embeds { equals/not/in } directly in its JSON, so
775
+ // unlike showBindingMatches there are no attrs to read or re-parse. The same
776
+ // literal-only vocabulary; anything else (empty, unknown keys, a non-array
777
+ // in:) returns null and the caller warn-skips that target (default-deny — a
778
+ // hand-built map entry must never flip visibility it doesn't declare).
779
+ function showPredicateMatches(pred, value) {
780
+ if (!pred || typeof pred !== "object") return null
781
+ if (typeof pred.equals === "string") return value === pred.equals
782
+ if (typeof pred.not === "string") return value !== pred.not
783
+ if (Array.isArray(pred.in)) return pred.in.includes(value)
784
+ return null
785
+ }
786
+
787
+ // A cross-root show target must be a single ID selector (issue #164) — the
788
+ // SAME shape the #159 mirror enforces (one shared regex), with its own warn so
789
+ // a refused show target is distinguishable in the console. The client half of
790
+ // the two-sided default-deny: reactive_show_targets raises at declare time; a
791
+ // hand-built wire attr must not widen the escape to class/compound selectors.
792
+ // A refused selector warns + skips — its siblings still apply.
793
+ function guardShowTargetSelector(selector) {
794
+ if (typeof selector === "string" && MIRROR_ID_SELECTOR.test(selector)) return true
795
+ console.warn(`[phlex-reactive] refused cross-root show target ${JSON.stringify(selector)} — skipped`)
796
+ return false
797
+ }
798
+
446
799
  // The first focusable descendant of `el`, in document order — the natural
447
800
  // keyboard target inside an opened menu/dialog. Covers the standard focusable
448
801
  // set; :not([tabindex="-1"]) drops explicitly-removed nodes. Returns null when
@@ -535,6 +888,15 @@ export default class extends Controller {
535
888
  #boundScanDirty
536
889
  #boundBeforeUnload
537
890
  #boundBeforeVisit
891
+ // Show bindings (issue #161): the ONE delegated sync handler shared by the
892
+ // root's input/change/turbo:morph-element listeners, held for teardown.
893
+ #boundSyncShow
894
+ // Option filtering (issue #163): the ONE delegated sync handler shared by the
895
+ // root's input/turbo:morph-element listeners, held for teardown.
896
+ #boundSyncFilter
897
+ // Lazy initial mount (issue #165): the bound re-probe attached to
898
+ // turbo:morph-element so a Turbo page-refresh morph re-fires the defer fetch.
899
+ #boundProbeLazyDefer
538
900
 
539
901
  // Mark that a reactive controller actually connected, so the registration
540
902
  // guard above knows the controller was registered (issue #26 part 2).
@@ -557,6 +919,25 @@ export default class extends Controller {
557
919
  )
558
920
  }
559
921
 
922
+ // Lazy initial mount (issue #165): a reactive_lazy shell carries its defer
923
+ // token as a ROOT attribute — enter the SAME module-level fetch path a
924
+ // reply directive uses (supersession, pending markers, error handling
925
+ // included). Probe on connect (a plain replace / cache restoration
926
+ // re-connects) AND on turbo:morph-element: a Turbo page-refresh MORPH
927
+ // re-shows the shell while keeping the element CONNECTED and firing no
928
+ // Stimulus lifecycle, so a connect-only probe would leave the morphed-in
929
+ // shell shimmering forever. The supersession registry makes a duplicate
930
+ // probe a no-op (same target id), so re-probing is safe. The attribute
931
+ // stays on the shell precisely so a re-appearance re-fires.
932
+ // Only wire the morph re-probe for a root that IS a lazy shell (carries the
933
+ // token) — a component that never uses reactive_lazy pays nothing (no
934
+ // listener), matching the dirty-tracking / show-sync gating precedent.
935
+ if (this.element.getAttribute?.("data-reactive-defer-token")) {
936
+ this.#probeLazyDefer()
937
+ this.#boundProbeLazyDefer = () => this.#probeLazyDefer()
938
+ this.element.addEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
939
+ }
940
+
560
941
  // Dirty tracking (issue #103) — ONLY when this root opts in (track_dirty: or a
561
942
  // reactive_field(dirty:)), so a component that never uses it pays nothing (no
562
943
  // baseline scan, no morph listener on every broadcast). A plain (outerHTML)
@@ -581,6 +962,43 @@ export default class extends Controller {
581
962
  this.#armUnsavedGuard()
582
963
  }
583
964
  }
965
+
966
+ // Show bindings (issue #161) — ONLY when this root owns one, so a component
967
+ // without any pays a single probe (the dirty-tracking gate precedent). ONE
968
+ // delegated listener pair on the root (input + change bubble from every
969
+ // owned field — no per-field wiring, and a reactive_compute output write
970
+ // dispatches a real input event, so computed values drive visibility too).
971
+ // The connect sync seeds the initial state — a plain replace re-connects —
972
+ // and turbo:morph-element re-syncs after an in-place morph (which keeps the
973
+ // element connected, fires no Stimulus lifecycle, and may preserve a
974
+ // user-edited field value the server's hidden attrs don't reflect).
975
+ if (this.#showSyncEnabled()) {
976
+ this.#boundSyncShow = () => this.#syncShow()
977
+ this.element.addEventListener?.("input", this.#boundSyncShow)
978
+ this.element.addEventListener?.("change", this.#boundSyncShow)
979
+ this.element.addEventListener?.("turbo:morph-element", this.#boundSyncShow)
980
+ this.#syncShow()
981
+ }
982
+
983
+ // Option filtering (issue #163) — ONLY when the root declares the binding
984
+ // (reactive_filter emits both attrs together), so a component without one
985
+ // pays two attribute reads. ONE delegated input listener on the root — the
986
+ // handler re-filters only for events from the NAMED input, so keystrokes in
987
+ // unrelated fields on a wide form never pay a filter pass. The connect sync
988
+ // seeds from the input's current value (a plain replace re-connects; back
989
+ // navigation may restore typed text), and turbo:morph-element re-applies
990
+ // after an in-place morph (which keeps the element connected, fires no
991
+ // Stimulus lifecycle, and may preserve the user's typed query while the
992
+ // server re-rendered every option visible).
993
+ if (this.#filterEnabled()) {
994
+ this.#boundSyncFilter = (event) => {
995
+ if (event?.type === "input" && !this.#filterInputEvent(event)) return
996
+ this.#syncFilter()
997
+ }
998
+ this.element.addEventListener?.("input", this.#boundSyncFilter)
999
+ this.element.addEventListener?.("turbo:morph-element", this.#boundSyncFilter)
1000
+ this.#syncFilter()
1001
+ }
584
1002
  }
585
1003
 
586
1004
  // Whether this root opts into dirty tracking (issue #103): track_dirty: puts the
@@ -605,6 +1023,27 @@ export default class extends Controller {
605
1023
  this.#clearAllDebounces()
606
1024
  this.#clearAllThrottles()
607
1025
  this.#teardownDirtyTracking()
1026
+ this.#teardownShowSync()
1027
+ this.#teardownFilterSync()
1028
+ if (this.#boundProbeLazyDefer) {
1029
+ this.element.removeEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
1030
+ }
1031
+ }
1032
+
1033
+ // Lazy initial mount probe (issue #165): fetch the real content when THIS
1034
+ // root is a reactive_lazy shell that still carries its defer token AND the
1035
+ // pending marker. Gating on the pending marker is what makes a re-probe (a
1036
+ // Turbo morph re-showing the shell) fire while a re-probe of an already
1037
+ // RESOLVED root (real content, no token, no marker) is a no-op. The
1038
+ // module-level supersession registry dedupes a duplicate in-flight fetch for
1039
+ // the same id, so calling this on both connect and every morph is safe.
1040
+ #probeLazyDefer() {
1041
+ const el = this.element
1042
+ if (!el?.id) return
1043
+ const token = el.getAttribute?.("data-reactive-defer-token")
1044
+ if (!token) return
1045
+ if (el.getAttribute?.("data-reactive-defer-pending") !== "true") return
1046
+ startFetchDefer(el.id, token)
608
1047
  }
609
1048
 
610
1049
  // Serialize requests per component. Each round trip rewrites the signed
@@ -900,7 +1339,9 @@ export default class extends Controller {
900
1339
  // unset. Falls back to the root for a directly-invoked call (unit tests). The
901
1340
  // ownership predicate is hoisted ONCE per keypress (issue #117) — in the common
902
1341
  // no-nested-root case it is a constant true, skipping a closest() walk per
903
- // option.
1342
+ // option. Hidden options are excluded (issue #163): a reactive_filter (or any
1343
+ // `hidden` toggle) removes a row from the keyboard path too, so an Arrow can't
1344
+ // highlight — and Enter can't pick — an invisible option.
904
1345
  #listnavOptions(event) {
905
1346
  const trigger = event?.currentTarget ?? event?.target ?? this.element
906
1347
  const selector =
@@ -908,7 +1349,7 @@ export default class extends Controller {
908
1349
  this.element.getAttribute("data-reactive-listnav-option-param")
909
1350
  if (!selector) return []
910
1351
  const owns = this.#ownershipFilter()
911
- return Array.from(this.element.querySelectorAll(selector)).filter(owns)
1352
+ return Array.from(this.element.querySelectorAll(selector)).filter((el) => !el.hidden && owns(el))
912
1353
  }
913
1354
 
914
1355
  // Parse a JSON string list from a root data attr; [] on absence/parse error so
@@ -1773,6 +2214,214 @@ export default class extends Controller {
1773
2214
  this.#boundBeforeVisit = undefined
1774
2215
  }
1775
2216
 
2217
+ // Whether this root owns a show binding (issue #161) or declares cross-root
2218
+ // show targets (issue #164) — the connect() gate, so a component with
2219
+ // neither pays only this probe (the #dirtyTrackingEnabled precedent). A
2220
+ // NESTED root's bindings don't count: its own controller instance syncs them
2221
+ // (issue #15 ownership). The targets attr is checked FIRST — one
2222
+ // getAttribute, cheaper than the binding walk.
2223
+ #showSyncEnabled() {
2224
+ if (this.element.getAttribute?.("data-reactive-show-targets")) return true
2225
+ const nodes = this.element.querySelectorAll?.("[data-reactive-show-field]") ?? []
2226
+ for (const el of nodes) if (this.#ownsField(el)) return true
2227
+ return false
2228
+ }
2229
+
2230
+ // Re-evaluate every OWNED show binding in one pass (issue #161): read the
2231
+ // controlling field's current value, evaluate the declared literal predicate,
2232
+ // toggle `hidden`. A full pass (not per-target) for the same reason as
2233
+ // #scanDirty — a radio group's deselected radio fires no event — and because
2234
+ // several bindings can hang off one field (the value read is memoized per
2235
+ // pass). A binding whose field can't be resolved, or whose predicate is
2236
+ // malformed, leaves visibility ALONE — a bad binding must never break or
2237
+ // blank the page (client-side default-deny).
2238
+ #syncShow() {
2239
+ if (typeof this.element?.querySelectorAll !== "function") return
2240
+
2241
+ const owns = this.#ownershipFilter()
2242
+ const values = new Map()
2243
+ for (const el of this.element.querySelectorAll("[data-reactive-show-field]")) {
2244
+ if (!owns(el)) continue // a nested root's binding is its own controller's job
2245
+ const name = el.getAttribute("data-reactive-show-field")
2246
+ if (!name) continue
2247
+ if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
2248
+ const value = values.get(name)
2249
+ if (value === null) continue // no owned field with that name — leave it be
2250
+ const match = showBindingMatches(el, value)
2251
+ if (match === null) continue // malformed predicate — warned + skipped
2252
+ el.hidden = !match
2253
+ }
2254
+
2255
+ // The cross-root pass (issue #164) shares the same owned-field memo, so a
2256
+ // field driving both an owned binding and an outside target reads once.
2257
+ this.#syncShowTargets(owns, values)
2258
+ }
2259
+
2260
+ // Apply the declared cross-root show targets (issue #164) — the visibility
2261
+ // parallel of #applyComputeMirrors. For each declared field: read the OWNED
2262
+ // field's current value (never a nested root's — you can only drive outside
2263
+ // visibility from a field this root owns), then for each "#id" → predicate
2264
+ // entry: guard the selector id-only (warn-and-skip; the Ruby helper raised
2265
+ // at declare time — two-sided default-deny), resolve it DOCUMENT-WIDE, and
2266
+ // toggle `hidden`. A target id not on the page is silently skipped (an
2267
+ // unrendered tab pane is normal); a malformed predicate warn-skips its one
2268
+ // target while siblings still apply. With no map declared this is one
2269
+ // getAttribute and out.
2270
+ #syncShowTargets(owns, values) {
2271
+ const map = this.#parseShowTargets()
2272
+ for (const [name, targets] of Object.entries(map)) {
2273
+ if (!targets || typeof targets !== "object" || Array.isArray(targets)) continue
2274
+ if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
2275
+ const value = values.get(name)
2276
+ if (value === null) continue // no owned field with that name — leave them be
2277
+ for (const [selector, pred] of Object.entries(targets)) {
2278
+ if (!guardShowTargetSelector(selector)) continue
2279
+ const match = showPredicateMatches(pred, value)
2280
+ if (match === null) {
2281
+ console.warn(`[phlex-reactive] malformed reactive_show_targets predicate for ${selector} — skipped`)
2282
+ continue
2283
+ }
2284
+ for (const node of document.querySelectorAll(selector)) node.hidden = !match
2285
+ }
2286
+ }
2287
+ }
2288
+
2289
+ // The declared cross-root show-target map (issue #164): a JSON object of
2290
+ // { field: { "#id": predicate } } from data-reactive-show-targets (emitted
2291
+ // by reactive_show_targets on the root). Absent degrades to {}; malformed
2292
+ // degrades to {} WITH a warn — never a throw (the #parseComputeMirror
2293
+ // contract), but never silent either: the likeliest cause is TWO
2294
+ // reactive_show_targets calls on one root, whose JSON strings Phlex `mix`
2295
+ // space-joined into an unparseable attr. The warn names the fix.
2296
+ #parseShowTargets() {
2297
+ const raw = this.element.getAttribute?.("data-reactive-show-targets")
2298
+ if (!raw) return {}
2299
+ try {
2300
+ const parsed = JSON.parse(raw)
2301
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed
2302
+ } catch {
2303
+ // fall through to the shared warn below
2304
+ }
2305
+ console.warn(
2306
+ "[phlex-reactive] malformed data-reactive-show-targets — ignored. " +
2307
+ "Did two reactive_show_targets calls collide on one root? Declare every field in ONE call: " +
2308
+ "reactive_show_targets(mode: { ... }, kind: { ... })"
2309
+ )
2310
+ return {}
2311
+ }
2312
+
2313
+ // The current value of the OWNED field controlling a show binding, as the
2314
+ // string the literal predicate compares against. Mirrors #collectFields'
2315
+ // per-kind reads: a checkbox reports its checked state ("true"/"false" — its
2316
+ // .value is the constant "on", and the checkbox wins over the hidden input
2317
+ // Rails pairs with it); a radio group reports the CHECKED radio's value (""
2318
+ // when none is); anything else reports .value first-wins. Returns null when
2319
+ // no owned field carries the name — the caller then leaves visibility alone.
2320
+ #showFieldValue(name, owns) {
2321
+ let sawRadio = false
2322
+ let first = null
2323
+ for (const el of this.element.querySelectorAll(`[name="${name}"]`)) {
2324
+ if (!owns(el)) continue
2325
+ if (el.type === "checkbox") return el.checked ? "true" : "false"
2326
+ if (el.type === "radio") {
2327
+ if (el.checked) return el.value ?? ""
2328
+ sawRadio = true
2329
+ continue
2330
+ }
2331
+ first ??= el
2332
+ }
2333
+ if (first) return first.value ?? ""
2334
+ return sawRadio ? "" : null
2335
+ }
2336
+
2337
+ // Remove the show-sync listeners on disconnect, so a stray event after a
2338
+ // Turbo morph/navigation never re-evaluates against a detached root.
2339
+ #teardownShowSync() {
2340
+ if (!this.#boundSyncShow) return
2341
+ this.element.removeEventListener?.("input", this.#boundSyncShow)
2342
+ this.element.removeEventListener?.("change", this.#boundSyncShow)
2343
+ this.element.removeEventListener?.("turbo:morph-element", this.#boundSyncShow)
2344
+ this.#boundSyncShow = undefined
2345
+ }
2346
+
2347
+ // Whether this root declares an option filter (issue #163) — the connect()
2348
+ // gate. reactive_filter always emits input + option together, so requiring
2349
+ // BOTH also default-denies a half-built hand-authored binding.
2350
+ #filterEnabled() {
2351
+ return !!(
2352
+ this.element.getAttribute?.("data-reactive-filter-input") &&
2353
+ this.element.getAttribute?.("data-reactive-filter-option")
2354
+ )
2355
+ }
2356
+
2357
+ // Whether a delegated input event came from the NAMED filter input (issue
2358
+ // #163). Anything else — another field's keystroke, a target without
2359
+ // matches() — skips the filter pass (the morph re-sync path bypasses this).
2360
+ #filterInputEvent(event) {
2361
+ const selector = this.element.getAttribute("data-reactive-filter-input")
2362
+ return !!selector && typeof event.target?.matches === "function" && event.target.matches(selector)
2363
+ }
2364
+
2365
+ // Re-apply the filter in one pass (issue #163): lowercase the named input's
2366
+ // current value, toggle `hidden` on every OWNED option by a substring match
2367
+ // against its haystack (data-reactive-filter-text, falling back to the
2368
+ // option's own text), collapse any group whose every contained option is
2369
+ // hidden, and reveal the empty target at 0 visible. A filtered-out option
2370
+ // also loses its listnav highlight so Enter can never pick an invisible row.
2371
+ // No owned input → leave visibility ALONE — a binding that can't resolve
2372
+ // must never break or blank the page (client-side default-deny). All
2373
+ // selectors resolve within this root, skipping nested reactive roots'
2374
+ // elements (issue #15 ownership; the predicate is hoisted once per pass).
2375
+ #syncFilter() {
2376
+ if (typeof this.element?.querySelectorAll !== "function") return
2377
+ const inputSelector = this.element.getAttribute("data-reactive-filter-input")
2378
+ const optionSelector = this.element.getAttribute("data-reactive-filter-option")
2379
+ if (!inputSelector || !optionSelector) return
2380
+
2381
+ const owns = this.#ownershipFilter()
2382
+ const input = [...this.element.querySelectorAll(inputSelector)].find(owns)
2383
+ if (!input) return
2384
+
2385
+ const query = (input.value ?? "").trim().toLowerCase()
2386
+ let visible = 0
2387
+ for (const el of this.element.querySelectorAll(optionSelector)) {
2388
+ if (!owns(el)) continue // a nested root's option is its own controller's job
2389
+ const haystack = (el.getAttribute("data-reactive-filter-text") ?? el.textContent ?? "").toLowerCase()
2390
+ const hidden = query !== "" && !haystack.includes(query)
2391
+ el.hidden = hidden
2392
+ if (hidden) el.removeAttribute("data-reactive-highlighted")
2393
+ else visible++
2394
+ }
2395
+
2396
+ const groupSelector = this.element.getAttribute("data-reactive-filter-group")
2397
+ if (groupSelector) {
2398
+ for (const group of this.element.querySelectorAll(groupSelector)) {
2399
+ if (!owns(group)) continue
2400
+ const contained = [...group.querySelectorAll(optionSelector)].filter(owns)
2401
+ // A group with no options isn't this filter's to decide — server state
2402
+ // stands (it may be a header the app toggles by other means).
2403
+ if (contained.length === 0) continue
2404
+ group.hidden = contained.every((el) => el.hidden)
2405
+ }
2406
+ }
2407
+
2408
+ const emptySelector = this.element.getAttribute("data-reactive-filter-empty")
2409
+ if (emptySelector) {
2410
+ for (const el of this.element.querySelectorAll(emptySelector)) {
2411
+ if (owns(el)) el.hidden = visible > 0
2412
+ }
2413
+ }
2414
+ }
2415
+
2416
+ // Remove the filter-sync listeners on disconnect, so a stray event after a
2417
+ // Turbo morph/navigation never re-filters against a detached root.
2418
+ #teardownFilterSync() {
2419
+ if (!this.#boundSyncFilter) return
2420
+ this.element.removeEventListener?.("input", this.#boundSyncFilter)
2421
+ this.element.removeEventListener?.("turbo:morph-element", this.#boundSyncFilter)
2422
+ this.#boundSyncFilter = undefined
2423
+ }
2424
+
1776
2425
  // Build the multipart body (issue #34). `token`/`act` are flat fields the
1777
2426
  // endpoint reads from params[:token]/params[:act]; scalar params nest under
1778
2427
  // params[<key>] (Rails parses the bracket into params[:params]); each file is