phlex-reactive 0.9.3 → 0.9.5

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 +212 -0
  3. data/README.md +282 -2
  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 +659 -10
  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 +348 -21
  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()
@@ -469,10 +766,139 @@ function showBindingMatches(el, value) {
469
766
  console.warn(`[phlex-reactive] malformed reactive_show in: list ${JSON.stringify(inRaw)} — skipped`)
470
767
  return null
471
768
  }
769
+ // Numeric threshold predicates (issue #176 part B): gte/gt/lte/lt read the
770
+ // literal off its own flat attr and compare Number(value) against it. Any
771
+ // present numeric attr decides the binding — a non-numeric field value (NaN)
772
+ // is false (hidden), and a non-numeric LITERAL warn-skips (null).
773
+ for (const key of SHOW_NUMERIC_KEYS) {
774
+ const raw = el.getAttribute(`data-reactive-show-${key}`)
775
+ if (raw !== null) return numericPredicateMatches(key, raw, value)
776
+ }
472
777
  console.warn("[phlex-reactive] a reactive_show binding declares no predicate — skipped")
473
778
  return null
474
779
  }
475
780
 
781
+ // The numeric threshold keys (issue #176 part B) — the client half of the Ruby
782
+ // SHOW_NUMERIC_KEYS. Order-independent; the evaluator reads the one that's
783
+ // present. Each coerces BOTH sides to Number and compares.
784
+ const SHOW_NUMERIC_KEYS = ["gte", "gt", "lte", "lt"]
785
+
786
+ // Evaluate one numeric threshold predicate against a field value. Returns
787
+ // true/false for a decidable comparison, or null when the LITERAL itself is
788
+ // non-numeric (a malformed binding — warn-skip, default-deny). A non-numeric
789
+ // FIELD value (empty/blank/garbage) is treated as NaN → false: the
790
+ // reveal-on-threshold notice stays hidden, the safe default. Shared by the
791
+ // owned-binding evaluator (raw string literal off an attr) and the
792
+ // cross-root/compound evaluator (a literal that arrived as a JSON number or
793
+ // string).
794
+ function numericPredicateMatches(key, literal, value) {
795
+ const rhs = Number(literal)
796
+ if (Number.isNaN(rhs)) {
797
+ console.warn(`[phlex-reactive] reactive_show ${key}: needs a numeric literal, got ${JSON.stringify(literal)} — skipped`)
798
+ return null
799
+ }
800
+ // A blank/whitespace field value must fail closed. Number("") and
801
+ // Number(" ") are 0 (NOT NaN), so a bare Number()+isNaN check would wrongly
802
+ // reveal a `lte:`/`lt:`/`gte: 0` binding on an EMPTY field. Force the
803
+ // empty/blank case to NaN so the "blank → hidden" contract holds for every
804
+ // operator, not just the ones where 0 happens to fail the comparison.
805
+ const trimmed = value == null ? "" : String(value).trim()
806
+ const n = trimmed === "" ? NaN : Number(trimmed)
807
+ if (Number.isNaN(n)) return false
808
+ switch (key) {
809
+ case "gte":
810
+ return n >= rhs
811
+ case "gt":
812
+ return n > rhs
813
+ case "lte":
814
+ return n <= rhs
815
+ case "lt":
816
+ return n < rhs
817
+ default:
818
+ return null
819
+ }
820
+ }
821
+
822
+ // Evaluate an ALREADY-PARSED show predicate object (issue #164) — the
823
+ // reactive_show_targets map embeds { equals/not/in } directly in its JSON, so
824
+ // unlike showBindingMatches there are no attrs to read or re-parse. The same
825
+ // literal-only vocabulary; anything else (empty, unknown keys, a non-array
826
+ // in:) returns null and the caller warn-skips that target (default-deny — a
827
+ // hand-built map entry must never flip visibility it doesn't declare).
828
+ function showPredicateMatches(pred, value) {
829
+ if (!pred || typeof pred !== "object") return null
830
+ if (typeof pred.equals === "string") return value === pred.equals
831
+ if (typeof pred.not === "string") return value !== pred.not
832
+ if (Array.isArray(pred.in)) return pred.in.includes(value)
833
+ // Numeric threshold predicates (issue #176 part B): the literal arrives as a
834
+ // JSON number (or a numeric string) embedded in the predicate object — one
835
+ // shared numericPredicateMatches with the owned-binding evaluator.
836
+ for (const key of SHOW_NUMERIC_KEYS) {
837
+ if (key in pred) return numericPredicateMatches(key, pred[key], value)
838
+ }
839
+ return null
840
+ }
841
+
842
+ // The selector matching every OWNED-element show binding: single-field
843
+ // (data-reactive-show-field, issue #161) OR compound all:/any:
844
+ // (data-reactive-show, issue #176). Both the connect() gate and the sync walk
845
+ // use it so a compound-only root still enables the sync.
846
+ const SHOW_BINDING_SELECTOR = "[data-reactive-show-field], [data-reactive-show]"
847
+
848
+ // Parse a compound show binding's JSON payload (issue #176 part A). Malformed
849
+ // JSON degrades to null WITH a warn — a bad binding must never throw or blank
850
+ // the page (client-side default-deny), but a collision (two bindings' JSON
851
+ // mix-joined) is worth surfacing.
852
+ function parseShowCompound(raw) {
853
+ try {
854
+ const parsed = JSON.parse(raw)
855
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed
856
+ } catch {
857
+ // fall through to the warn
858
+ }
859
+ console.warn(`[phlex-reactive] malformed compound reactive_show payload ${JSON.stringify(raw)} — skipped`)
860
+ return null
861
+ }
862
+
863
+ // Evaluate a COMPOUND show binding (issue #176 part A): a parsed
864
+ // { all: [term, …] } or { any: [term, …] } payload, where each term is
865
+ // { field, <predicate> }. `fieldValue(name)` resolves the OWNED field's current
866
+ // value (memoized by the caller). all: ANDs every term, any: ORs them. A term
867
+ // whose field is missing (null) or whose predicate is malformed/unknown folds
868
+ // as FALSE — fail-closed (the issue's default-deny lean): a broken AND term can
869
+ // never pass, a broken OR term can never reveal. Returns true/false for a
870
+ // decidable payload, or null for a malformed payload (no all:/any: array) so
871
+ // the caller warn-skips and leaves visibility alone.
872
+ function compoundShowMatches(payload, fieldValue) {
873
+ if (!payload || typeof payload !== "object") return null
874
+ const connective = Array.isArray(payload.all) ? "all" : Array.isArray(payload.any) ? "any" : null
875
+ if (!connective) return null
876
+ const terms = payload[connective]
877
+ if (terms.length === 0) return null // an empty compound decides nothing — skip
878
+
879
+ const results = terms.map((term) => {
880
+ if (!term || typeof term !== "object" || typeof term.field !== "string") return false
881
+ const value = fieldValue(term.field)
882
+ if (value === null) return false // no owned field with that name → fail-closed
883
+ const match = showPredicateMatches(term, value)
884
+ return match === true // null (malformed term) or false both fold to false
885
+ })
886
+
887
+ return connective === "all" ? results.every(Boolean) : results.some(Boolean)
888
+ }
889
+
890
+ // A cross-root show target must be a single ID selector (issue #164) — the
891
+ // SAME shape the #159 mirror enforces (one shared regex), with its own warn so
892
+ // a refused show target is distinguishable in the console. The client half of
893
+ // the two-sided default-deny: reactive_show_targets raises at declare time; a
894
+ // hand-built wire attr must not widen the escape to class/compound selectors.
895
+ // A refused selector warns + skips — its siblings still apply.
896
+ function guardShowTargetSelector(selector) {
897
+ if (typeof selector === "string" && MIRROR_ID_SELECTOR.test(selector)) return true
898
+ console.warn(`[phlex-reactive] refused cross-root show target ${JSON.stringify(selector)} — skipped`)
899
+ return false
900
+ }
901
+
476
902
  // The first focusable descendant of `el`, in document order — the natural
477
903
  // keyboard target inside an opened menu/dialog. Covers the standard focusable
478
904
  // set; :not([tabindex="-1"]) drops explicitly-removed nodes. Returns null when
@@ -568,6 +994,12 @@ export default class extends Controller {
568
994
  // Show bindings (issue #161): the ONE delegated sync handler shared by the
569
995
  // root's input/change/turbo:morph-element listeners, held for teardown.
570
996
  #boundSyncShow
997
+ // Option filtering (issue #163): the ONE delegated sync handler shared by the
998
+ // root's input/turbo:morph-element listeners, held for teardown.
999
+ #boundSyncFilter
1000
+ // Lazy initial mount (issue #165): the bound re-probe attached to
1001
+ // turbo:morph-element so a Turbo page-refresh morph re-fires the defer fetch.
1002
+ #boundProbeLazyDefer
571
1003
 
572
1004
  // Mark that a reactive controller actually connected, so the registration
573
1005
  // guard above knows the controller was registered (issue #26 part 2).
@@ -590,6 +1022,25 @@ export default class extends Controller {
590
1022
  )
591
1023
  }
592
1024
 
1025
+ // Lazy initial mount (issue #165): a reactive_lazy shell carries its defer
1026
+ // token as a ROOT attribute — enter the SAME module-level fetch path a
1027
+ // reply directive uses (supersession, pending markers, error handling
1028
+ // included). Probe on connect (a plain replace / cache restoration
1029
+ // re-connects) AND on turbo:morph-element: a Turbo page-refresh MORPH
1030
+ // re-shows the shell while keeping the element CONNECTED and firing no
1031
+ // Stimulus lifecycle, so a connect-only probe would leave the morphed-in
1032
+ // shell shimmering forever. The supersession registry makes a duplicate
1033
+ // probe a no-op (same target id), so re-probing is safe. The attribute
1034
+ // stays on the shell precisely so a re-appearance re-fires.
1035
+ // Only wire the morph re-probe for a root that IS a lazy shell (carries the
1036
+ // token) — a component that never uses reactive_lazy pays nothing (no
1037
+ // listener), matching the dirty-tracking / show-sync gating precedent.
1038
+ if (this.element.getAttribute?.("data-reactive-defer-token")) {
1039
+ this.#probeLazyDefer()
1040
+ this.#boundProbeLazyDefer = () => this.#probeLazyDefer()
1041
+ this.element.addEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
1042
+ }
1043
+
593
1044
  // Dirty tracking (issue #103) — ONLY when this root opts in (track_dirty: or a
594
1045
  // reactive_field(dirty:)), so a component that never uses it pays nothing (no
595
1046
  // baseline scan, no morph listener on every broadcast). A plain (outerHTML)
@@ -631,6 +1082,26 @@ export default class extends Controller {
631
1082
  this.element.addEventListener?.("turbo:morph-element", this.#boundSyncShow)
632
1083
  this.#syncShow()
633
1084
  }
1085
+
1086
+ // Option filtering (issue #163) — ONLY when the root declares the binding
1087
+ // (reactive_filter emits both attrs together), so a component without one
1088
+ // pays two attribute reads. ONE delegated input listener on the root — the
1089
+ // handler re-filters only for events from the NAMED input, so keystrokes in
1090
+ // unrelated fields on a wide form never pay a filter pass. The connect sync
1091
+ // seeds from the input's current value (a plain replace re-connects; back
1092
+ // navigation may restore typed text), and turbo:morph-element re-applies
1093
+ // after an in-place morph (which keeps the element connected, fires no
1094
+ // Stimulus lifecycle, and may preserve the user's typed query while the
1095
+ // server re-rendered every option visible).
1096
+ if (this.#filterEnabled()) {
1097
+ this.#boundSyncFilter = (event) => {
1098
+ if (event?.type === "input" && !this.#filterInputEvent(event)) return
1099
+ this.#syncFilter()
1100
+ }
1101
+ this.element.addEventListener?.("input", this.#boundSyncFilter)
1102
+ this.element.addEventListener?.("turbo:morph-element", this.#boundSyncFilter)
1103
+ this.#syncFilter()
1104
+ }
634
1105
  }
635
1106
 
636
1107
  // Whether this root opts into dirty tracking (issue #103): track_dirty: puts the
@@ -656,6 +1127,26 @@ export default class extends Controller {
656
1127
  this.#clearAllThrottles()
657
1128
  this.#teardownDirtyTracking()
658
1129
  this.#teardownShowSync()
1130
+ this.#teardownFilterSync()
1131
+ if (this.#boundProbeLazyDefer) {
1132
+ this.element.removeEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
1133
+ }
1134
+ }
1135
+
1136
+ // Lazy initial mount probe (issue #165): fetch the real content when THIS
1137
+ // root is a reactive_lazy shell that still carries its defer token AND the
1138
+ // pending marker. Gating on the pending marker is what makes a re-probe (a
1139
+ // Turbo morph re-showing the shell) fire while a re-probe of an already
1140
+ // RESOLVED root (real content, no token, no marker) is a no-op. The
1141
+ // module-level supersession registry dedupes a duplicate in-flight fetch for
1142
+ // the same id, so calling this on both connect and every morph is safe.
1143
+ #probeLazyDefer() {
1144
+ const el = this.element
1145
+ if (!el?.id) return
1146
+ const token = el.getAttribute?.("data-reactive-defer-token")
1147
+ if (!token) return
1148
+ if (el.getAttribute?.("data-reactive-defer-pending") !== "true") return
1149
+ startFetchDefer(el.id, token)
659
1150
  }
660
1151
 
661
1152
  // Serialize requests per component. Each round trip rewrites the signed
@@ -951,7 +1442,9 @@ export default class extends Controller {
951
1442
  // unset. Falls back to the root for a directly-invoked call (unit tests). The
952
1443
  // ownership predicate is hoisted ONCE per keypress (issue #117) — in the common
953
1444
  // no-nested-root case it is a constant true, skipping a closest() walk per
954
- // option.
1445
+ // option. Hidden options are excluded (issue #163): a reactive_filter (or any
1446
+ // `hidden` toggle) removes a row from the keyboard path too, so an Arrow can't
1447
+ // highlight — and Enter can't pick — an invisible option.
955
1448
  #listnavOptions(event) {
956
1449
  const trigger = event?.currentTarget ?? event?.target ?? this.element
957
1450
  const selector =
@@ -959,7 +1452,7 @@ export default class extends Controller {
959
1452
  this.element.getAttribute("data-reactive-listnav-option-param")
960
1453
  if (!selector) return []
961
1454
  const owns = this.#ownershipFilter()
962
- return Array.from(this.element.querySelectorAll(selector)).filter(owns)
1455
+ return Array.from(this.element.querySelectorAll(selector)).filter((el) => !el.hidden && owns(el))
963
1456
  }
964
1457
 
965
1458
  // Parse a JSON string list from a root data attr; [] on absence/parse error so
@@ -1824,12 +2317,18 @@ export default class extends Controller {
1824
2317
  this.#boundBeforeVisit = undefined
1825
2318
  }
1826
2319
 
1827
- // Whether this root owns a show binding (issue #161) the connect() gate, so
1828
- // a component without one pays only this probe (the #dirtyTrackingEnabled
1829
- // precedent). A NESTED root's bindings don't count: its own controller
1830
- // instance syncs them (issue #15 ownership).
2320
+ // Whether this root owns a show binding (issue #161) or declares cross-root
2321
+ // show targets (issue #164) the connect() gate, so a component with
2322
+ // neither pays only this probe (the #dirtyTrackingEnabled precedent). A
2323
+ // NESTED root's bindings don't count: its own controller instance syncs them
2324
+ // (issue #15 ownership). The targets attr is checked FIRST — one
2325
+ // getAttribute, cheaper than the binding walk.
1831
2326
  #showSyncEnabled() {
1832
- const nodes = this.element.querySelectorAll?.("[data-reactive-show-field]") ?? []
2327
+ if (this.element.getAttribute?.("data-reactive-show-targets")) return true
2328
+ // Single-field bindings carry -field; compound all:/any: bindings (issue
2329
+ // #176) carry data-reactive-show and have NO single controlling field, so
2330
+ // both selectors gate the sync.
2331
+ const nodes = this.element.querySelectorAll?.(SHOW_BINDING_SELECTOR) ?? []
1833
2332
  for (const el of nodes) if (this.#ownsField(el)) return true
1834
2333
  return false
1835
2334
  }
@@ -1847,17 +2346,89 @@ export default class extends Controller {
1847
2346
 
1848
2347
  const owns = this.#ownershipFilter()
1849
2348
  const values = new Map()
1850
- for (const el of this.element.querySelectorAll("[data-reactive-show-field]")) {
2349
+ // A memoized resolver shared by every binding in this pass — a field driving
2350
+ // several bindings (and several terms of a compound) reads exactly once.
2351
+ const fieldValue = (name) => {
2352
+ if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
2353
+ return values.get(name)
2354
+ }
2355
+ for (const el of this.element.querySelectorAll(SHOW_BINDING_SELECTOR)) {
1851
2356
  if (!owns(el)) continue // a nested root's binding is its own controller's job
2357
+
2358
+ // Compound all:/any: (issue #176 part A): no single -field attr; parse the
2359
+ // JSON payload and fold its per-field terms.
2360
+ const compoundRaw = el.getAttribute("data-reactive-show")
2361
+ if (compoundRaw !== null) {
2362
+ const match = compoundShowMatches(parseShowCompound(compoundRaw), fieldValue)
2363
+ if (match !== null) el.hidden = !match
2364
+ continue
2365
+ }
2366
+
1852
2367
  const name = el.getAttribute("data-reactive-show-field")
1853
2368
  if (!name) continue
1854
- if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
1855
- const value = values.get(name)
2369
+ const value = fieldValue(name)
1856
2370
  if (value === null) continue // no owned field with that name — leave it be
1857
2371
  const match = showBindingMatches(el, value)
1858
2372
  if (match === null) continue // malformed predicate — warned + skipped
1859
2373
  el.hidden = !match
1860
2374
  }
2375
+
2376
+ // The cross-root pass (issue #164) shares the same owned-field memo, so a
2377
+ // field driving both an owned binding and an outside target reads once.
2378
+ this.#syncShowTargets(owns, values)
2379
+ }
2380
+
2381
+ // Apply the declared cross-root show targets (issue #164) — the visibility
2382
+ // parallel of #applyComputeMirrors. For each declared field: read the OWNED
2383
+ // field's current value (never a nested root's — you can only drive outside
2384
+ // visibility from a field this root owns), then for each "#id" → predicate
2385
+ // entry: guard the selector id-only (warn-and-skip; the Ruby helper raised
2386
+ // at declare time — two-sided default-deny), resolve it DOCUMENT-WIDE, and
2387
+ // toggle `hidden`. A target id not on the page is silently skipped (an
2388
+ // unrendered tab pane is normal); a malformed predicate warn-skips its one
2389
+ // target while siblings still apply. With no map declared this is one
2390
+ // getAttribute and out.
2391
+ #syncShowTargets(owns, values) {
2392
+ const map = this.#parseShowTargets()
2393
+ for (const [name, targets] of Object.entries(map)) {
2394
+ if (!targets || typeof targets !== "object" || Array.isArray(targets)) continue
2395
+ if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
2396
+ const value = values.get(name)
2397
+ if (value === null) continue // no owned field with that name — leave them be
2398
+ for (const [selector, pred] of Object.entries(targets)) {
2399
+ if (!guardShowTargetSelector(selector)) continue
2400
+ const match = showPredicateMatches(pred, value)
2401
+ if (match === null) {
2402
+ console.warn(`[phlex-reactive] malformed reactive_show_targets predicate for ${selector} — skipped`)
2403
+ continue
2404
+ }
2405
+ for (const node of document.querySelectorAll(selector)) node.hidden = !match
2406
+ }
2407
+ }
2408
+ }
2409
+
2410
+ // The declared cross-root show-target map (issue #164): a JSON object of
2411
+ // { field: { "#id": predicate } } from data-reactive-show-targets (emitted
2412
+ // by reactive_show_targets on the root). Absent degrades to {}; malformed
2413
+ // degrades to {} WITH a warn — never a throw (the #parseComputeMirror
2414
+ // contract), but never silent either: the likeliest cause is TWO
2415
+ // reactive_show_targets calls on one root, whose JSON strings Phlex `mix`
2416
+ // space-joined into an unparseable attr. The warn names the fix.
2417
+ #parseShowTargets() {
2418
+ const raw = this.element.getAttribute?.("data-reactive-show-targets")
2419
+ if (!raw) return {}
2420
+ try {
2421
+ const parsed = JSON.parse(raw)
2422
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed
2423
+ } catch {
2424
+ // fall through to the shared warn below
2425
+ }
2426
+ console.warn(
2427
+ "[phlex-reactive] malformed data-reactive-show-targets — ignored. " +
2428
+ "Did two reactive_show_targets calls collide on one root? Declare every field in ONE call: " +
2429
+ "reactive_show_targets(mode: { ... }, kind: { ... })"
2430
+ )
2431
+ return {}
1861
2432
  }
1862
2433
 
1863
2434
  // The current value of the OWNED field controlling a show binding, as the
@@ -1894,6 +2465,84 @@ export default class extends Controller {
1894
2465
  this.#boundSyncShow = undefined
1895
2466
  }
1896
2467
 
2468
+ // Whether this root declares an option filter (issue #163) — the connect()
2469
+ // gate. reactive_filter always emits input + option together, so requiring
2470
+ // BOTH also default-denies a half-built hand-authored binding.
2471
+ #filterEnabled() {
2472
+ return !!(
2473
+ this.element.getAttribute?.("data-reactive-filter-input") &&
2474
+ this.element.getAttribute?.("data-reactive-filter-option")
2475
+ )
2476
+ }
2477
+
2478
+ // Whether a delegated input event came from the NAMED filter input (issue
2479
+ // #163). Anything else — another field's keystroke, a target without
2480
+ // matches() — skips the filter pass (the morph re-sync path bypasses this).
2481
+ #filterInputEvent(event) {
2482
+ const selector = this.element.getAttribute("data-reactive-filter-input")
2483
+ return !!selector && typeof event.target?.matches === "function" && event.target.matches(selector)
2484
+ }
2485
+
2486
+ // Re-apply the filter in one pass (issue #163): lowercase the named input's
2487
+ // current value, toggle `hidden` on every OWNED option by a substring match
2488
+ // against its haystack (data-reactive-filter-text, falling back to the
2489
+ // option's own text), collapse any group whose every contained option is
2490
+ // hidden, and reveal the empty target at 0 visible. A filtered-out option
2491
+ // also loses its listnav highlight so Enter can never pick an invisible row.
2492
+ // No owned input → leave visibility ALONE — a binding that can't resolve
2493
+ // must never break or blank the page (client-side default-deny). All
2494
+ // selectors resolve within this root, skipping nested reactive roots'
2495
+ // elements (issue #15 ownership; the predicate is hoisted once per pass).
2496
+ #syncFilter() {
2497
+ if (typeof this.element?.querySelectorAll !== "function") return
2498
+ const inputSelector = this.element.getAttribute("data-reactive-filter-input")
2499
+ const optionSelector = this.element.getAttribute("data-reactive-filter-option")
2500
+ if (!inputSelector || !optionSelector) return
2501
+
2502
+ const owns = this.#ownershipFilter()
2503
+ const input = [...this.element.querySelectorAll(inputSelector)].find(owns)
2504
+ if (!input) return
2505
+
2506
+ const query = (input.value ?? "").trim().toLowerCase()
2507
+ let visible = 0
2508
+ for (const el of this.element.querySelectorAll(optionSelector)) {
2509
+ if (!owns(el)) continue // a nested root's option is its own controller's job
2510
+ const haystack = (el.getAttribute("data-reactive-filter-text") ?? el.textContent ?? "").toLowerCase()
2511
+ const hidden = query !== "" && !haystack.includes(query)
2512
+ el.hidden = hidden
2513
+ if (hidden) el.removeAttribute("data-reactive-highlighted")
2514
+ else visible++
2515
+ }
2516
+
2517
+ const groupSelector = this.element.getAttribute("data-reactive-filter-group")
2518
+ if (groupSelector) {
2519
+ for (const group of this.element.querySelectorAll(groupSelector)) {
2520
+ if (!owns(group)) continue
2521
+ const contained = [...group.querySelectorAll(optionSelector)].filter(owns)
2522
+ // A group with no options isn't this filter's to decide — server state
2523
+ // stands (it may be a header the app toggles by other means).
2524
+ if (contained.length === 0) continue
2525
+ group.hidden = contained.every((el) => el.hidden)
2526
+ }
2527
+ }
2528
+
2529
+ const emptySelector = this.element.getAttribute("data-reactive-filter-empty")
2530
+ if (emptySelector) {
2531
+ for (const el of this.element.querySelectorAll(emptySelector)) {
2532
+ if (owns(el)) el.hidden = visible > 0
2533
+ }
2534
+ }
2535
+ }
2536
+
2537
+ // Remove the filter-sync listeners on disconnect, so a stray event after a
2538
+ // Turbo morph/navigation never re-filters against a detached root.
2539
+ #teardownFilterSync() {
2540
+ if (!this.#boundSyncFilter) return
2541
+ this.element.removeEventListener?.("input", this.#boundSyncFilter)
2542
+ this.element.removeEventListener?.("turbo:morph-element", this.#boundSyncFilter)
2543
+ this.#boundSyncFilter = undefined
2544
+ }
2545
+
1897
2546
  // Build the multipart body (issue #34). `token`/`act` are flat fields the
1898
2547
  // endpoint reads from params[:token]/params[:act]; scalar params nest under
1899
2548
  // params[<key>] (Rails parses the bracket into params[:params]); each file is