phlex-reactive 0.9.3 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +197 -0
- data/README.md +282 -2
- data/app/controllers/phlex/reactive/actions_controller.rb +120 -10
- data/app/javascript/phlex/reactive/inspect.js +225 -0
- data/app/javascript/phlex/reactive/inspect.min.js +4 -0
- data/app/javascript/phlex/reactive/inspect.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +534 -6
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/generators/phlex/reactive/claude/USAGE +15 -0
- data/lib/generators/phlex/reactive/claude/claude_generator.rb +86 -0
- data/lib/generators/phlex/reactive/install/install_generator.rb +3 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +56 -0
- data/lib/phlex/reactive/authorization.rb +118 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +105 -0
- data/lib/phlex/reactive/component/dsl.rb +70 -0
- data/lib/phlex/reactive/component/helpers.rb +193 -8
- data/lib/phlex/reactive/component/identity.rb +10 -1
- data/lib/phlex/reactive/component/lazy.rb +79 -0
- data/lib/phlex/reactive/component/registry.rb +7 -1
- data/lib/phlex/reactive/component.rb +1 -0
- data/lib/phlex/reactive/defer.rb +363 -0
- data/lib/phlex/reactive/deferred_render_job.rb +116 -0
- data/lib/phlex/reactive/doctor.rb +79 -11
- data/lib/phlex/reactive/engine.rb +16 -2
- data/lib/phlex/reactive/inspector/report.rb +140 -0
- data/lib/phlex/reactive/inspector.rb +253 -0
- data/lib/phlex/reactive/log_subscriber.rb +10 -0
- data/lib/phlex/reactive/mcp/base_tool.rb +57 -0
- data/lib/phlex/reactive/mcp/runner.rb +24 -0
- data/lib/phlex/reactive/mcp/server.rb +47 -0
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +43 -0
- data/lib/phlex/reactive/mcp/tools/components_tool.rb +39 -0
- data/lib/phlex/reactive/mcp/tools/config_tool.rb +74 -0
- data/lib/phlex/reactive/mcp/tools/doctor_tool.rb +36 -0
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +66 -0
- data/lib/phlex/reactive/mcp.rb +58 -0
- data/lib/phlex/reactive/reply.rb +18 -0
- data/lib/phlex/reactive/response.rb +47 -2
- data/lib/phlex/reactive/streamable.rb +5 -1
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +253 -3
- data/lib/tasks/phlex_reactive.rake +28 -0
- 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()
|
|
@@ -473,6 +770,32 @@ function showBindingMatches(el, value) {
|
|
|
473
770
|
return null
|
|
474
771
|
}
|
|
475
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
|
+
|
|
476
799
|
// The first focusable descendant of `el`, in document order — the natural
|
|
477
800
|
// keyboard target inside an opened menu/dialog. Covers the standard focusable
|
|
478
801
|
// set; :not([tabindex="-1"]) drops explicitly-removed nodes. Returns null when
|
|
@@ -568,6 +891,12 @@ export default class extends Controller {
|
|
|
568
891
|
// Show bindings (issue #161): the ONE delegated sync handler shared by the
|
|
569
892
|
// root's input/change/turbo:morph-element listeners, held for teardown.
|
|
570
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
|
|
571
900
|
|
|
572
901
|
// Mark that a reactive controller actually connected, so the registration
|
|
573
902
|
// guard above knows the controller was registered (issue #26 part 2).
|
|
@@ -590,6 +919,25 @@ export default class extends Controller {
|
|
|
590
919
|
)
|
|
591
920
|
}
|
|
592
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
|
+
|
|
593
941
|
// Dirty tracking (issue #103) — ONLY when this root opts in (track_dirty: or a
|
|
594
942
|
// reactive_field(dirty:)), so a component that never uses it pays nothing (no
|
|
595
943
|
// baseline scan, no morph listener on every broadcast). A plain (outerHTML)
|
|
@@ -631,6 +979,26 @@ export default class extends Controller {
|
|
|
631
979
|
this.element.addEventListener?.("turbo:morph-element", this.#boundSyncShow)
|
|
632
980
|
this.#syncShow()
|
|
633
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
|
+
}
|
|
634
1002
|
}
|
|
635
1003
|
|
|
636
1004
|
// Whether this root opts into dirty tracking (issue #103): track_dirty: puts the
|
|
@@ -656,6 +1024,26 @@ export default class extends Controller {
|
|
|
656
1024
|
this.#clearAllThrottles()
|
|
657
1025
|
this.#teardownDirtyTracking()
|
|
658
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)
|
|
659
1047
|
}
|
|
660
1048
|
|
|
661
1049
|
// Serialize requests per component. Each round trip rewrites the signed
|
|
@@ -951,7 +1339,9 @@ export default class extends Controller {
|
|
|
951
1339
|
// unset. Falls back to the root for a directly-invoked call (unit tests). The
|
|
952
1340
|
// ownership predicate is hoisted ONCE per keypress (issue #117) — in the common
|
|
953
1341
|
// no-nested-root case it is a constant true, skipping a closest() walk per
|
|
954
|
-
// 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.
|
|
955
1345
|
#listnavOptions(event) {
|
|
956
1346
|
const trigger = event?.currentTarget ?? event?.target ?? this.element
|
|
957
1347
|
const selector =
|
|
@@ -959,7 +1349,7 @@ export default class extends Controller {
|
|
|
959
1349
|
this.element.getAttribute("data-reactive-listnav-option-param")
|
|
960
1350
|
if (!selector) return []
|
|
961
1351
|
const owns = this.#ownershipFilter()
|
|
962
|
-
return Array.from(this.element.querySelectorAll(selector)).filter(owns)
|
|
1352
|
+
return Array.from(this.element.querySelectorAll(selector)).filter((el) => !el.hidden && owns(el))
|
|
963
1353
|
}
|
|
964
1354
|
|
|
965
1355
|
// Parse a JSON string list from a root data attr; [] on absence/parse error so
|
|
@@ -1824,11 +2214,14 @@ export default class extends Controller {
|
|
|
1824
2214
|
this.#boundBeforeVisit = undefined
|
|
1825
2215
|
}
|
|
1826
2216
|
|
|
1827
|
-
// Whether this root owns a show binding (issue #161)
|
|
1828
|
-
//
|
|
1829
|
-
//
|
|
1830
|
-
// instance syncs them
|
|
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.
|
|
1831
2223
|
#showSyncEnabled() {
|
|
2224
|
+
if (this.element.getAttribute?.("data-reactive-show-targets")) return true
|
|
1832
2225
|
const nodes = this.element.querySelectorAll?.("[data-reactive-show-field]") ?? []
|
|
1833
2226
|
for (const el of nodes) if (this.#ownsField(el)) return true
|
|
1834
2227
|
return false
|
|
@@ -1858,6 +2251,63 @@ export default class extends Controller {
|
|
|
1858
2251
|
if (match === null) continue // malformed predicate — warned + skipped
|
|
1859
2252
|
el.hidden = !match
|
|
1860
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 {}
|
|
1861
2311
|
}
|
|
1862
2312
|
|
|
1863
2313
|
// The current value of the OWNED field controlling a show binding, as the
|
|
@@ -1894,6 +2344,84 @@ export default class extends Controller {
|
|
|
1894
2344
|
this.#boundSyncShow = undefined
|
|
1895
2345
|
}
|
|
1896
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
|
+
|
|
1897
2425
|
// Build the multipart body (issue #34). `token`/`act` are flat fields the
|
|
1898
2426
|
// endpoint reads from params[:token]/params[:act]; scalar params nest under
|
|
1899
2427
|
// params[<key>] (Rails parses the bracket into params[:params]); each file is
|