phlex-reactive 0.13.4 → 0.14.1

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1079 -630
  3. data/README.md +612 -17
  4. data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
  5. data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
  6. data/app/javascript/phlex/reactive/core.js +181 -0
  7. data/app/javascript/phlex/reactive/core.min.js +4 -0
  8. data/app/javascript/phlex/reactive/core.min.js.map +11 -0
  9. data/app/javascript/phlex/reactive/early.js +175 -0
  10. data/app/javascript/phlex/reactive/early.min.js +4 -0
  11. data/app/javascript/phlex/reactive/early.min.js.map +10 -0
  12. data/app/javascript/phlex/reactive/features/bindings.js +1582 -0
  13. data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
  14. data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
  15. data/app/javascript/phlex/reactive/features/compute.js +541 -0
  16. data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
  17. data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
  18. data/app/javascript/phlex/reactive/features/defer.js +726 -0
  19. data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
  20. data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
  21. data/app/javascript/phlex/reactive/features/devtools.js +242 -0
  22. data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
  23. data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
  24. data/app/javascript/phlex/reactive/features/effects.js +362 -0
  25. data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
  26. data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
  27. data/app/javascript/phlex/reactive/features/form.js +216 -0
  28. data/app/javascript/phlex/reactive/features/form.min.js +4 -0
  29. data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
  30. data/app/javascript/phlex/reactive/features/hints.js +193 -0
  31. data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
  32. data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
  33. data/app/javascript/phlex/reactive/features/persist.js +663 -0
  34. data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
  35. data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
  36. data/app/javascript/phlex/reactive/reactive_controller.js +86 -5821
  37. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  38. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
  39. data/app/javascript/phlex/reactive/runtime.js +3706 -0
  40. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
  41. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
  42. data/lib/phlex/reactive/component/dsl.rb +74 -2
  43. data/lib/phlex/reactive/component/helpers.rb +39 -9
  44. data/lib/phlex/reactive/component/lazy.rb +138 -2
  45. data/lib/phlex/reactive/component/registry.rb +2 -0
  46. data/lib/phlex/reactive/component/selection.rb +188 -0
  47. data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
  48. data/lib/phlex/reactive/doctor.rb +166 -8
  49. data/lib/phlex/reactive/dormant.rb +66 -0
  50. data/lib/phlex/reactive/engine.rb +63 -3
  51. data/lib/phlex/reactive/fragment.rb +233 -0
  52. data/lib/phlex/reactive/inspector/report.rb +24 -3
  53. data/lib/phlex/reactive/inspector.rb +23 -2
  54. data/lib/phlex/reactive/js.rb +47 -2
  55. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
  56. data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
  57. data/lib/phlex/reactive/param_schema.rb +68 -54
  58. data/lib/phlex/reactive/show_conditions.rb +84 -56
  59. data/lib/phlex/reactive/streamable.rb +27 -7
  60. data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
  61. data/lib/phlex/reactive/test_helpers/system.rb +62 -0
  62. data/lib/phlex/reactive/version.rb +1 -1
  63. data/lib/phlex/reactive.rb +84 -0
  64. data/lib/tasks/phlex_reactive.rake +6 -2
  65. metadata +38 -1
@@ -0,0 +1,726 @@
1
+ // Deferred and lazy renders — a FEATURE MODULE of the reactive controller
2
+ // (issue #275). The core imports it only when a root on the page is a
3
+ // reactive_lazy shell (it carries data-reactive-defer-token, -defer-src,
4
+ // -lazy-on or -lazy-visible), when a morph turns a root into one, or when a
5
+ // `reactive:defer` stream arrives.
6
+ //
7
+ // Three things live here:
8
+ //
9
+ // reply.defer (issue #165) the client half: the server's reply carries a
10
+ // `<turbo-stream action="reactive:defer" target="<id>">` directive and the
11
+ // real render reaches the SAME actor later — via a parallel fetch (pull) or
12
+ // a pgbus one-shot stream (push).
13
+ // reactive_lazy (issue #165) a shell that fetches its real content when it
14
+ // connects; with cache: (issue #277) from a stable, privately cacheable URL.
15
+ // reactive_lazy(on:) (issue #276) a shell that loads on an event, or when it
16
+ // scrolls into view.
17
+ //
18
+ // Everything about a delivery is MODULE-level, deliberately OFF the
19
+ // per-controller request queue: the whole point is that the expensive segment
20
+ // never blocks the actor's next action.
21
+ //
22
+ // Supersession is the correctness core: pendingDefers keys one in-flight
23
+ // delivery per target id. A newer directive for the same target aborts the
24
+ // older fetch (or removes the older stream source), and an arrival applies
25
+ // ONLY while its entry is still current — so a fast typist's debounced
26
+ // keystrokes can never paint stale totals over fresh ones.
27
+ //
28
+ // The feature contract (reactive_controller.js "Feature modules"):
29
+ // install(shared) once, when the module has loaded
30
+ // connect(controller, core, morphed) per root connection
31
+ // disconnect(controller) before the core's own teardown
32
+ // A feature never imports the core. `shared` carries the core's single-
33
+ // instance counters (the activity signal, the request totals); `core` is the
34
+ // per-controller handle: core.emit, core.proceed (the action pipeline) and
35
+ // core.forgetToken.
36
+
37
+ // The core's module-level counters, handed over once by install(): there must
38
+ // be ONE activity count and one request total on a page, whoever adds to them.
39
+ let shared
40
+
41
+ export function install(given) {
42
+ shared = given
43
+ }
44
+
45
+ const pendingDefers = new Map()
46
+
47
+ // Register/drop a target's pending defer entry, keeping the GLOBAL activity
48
+ // counter (issue #201) balanced against the Map's ACTUAL key presence — a defer
49
+ // is one in-flight reactive operation for as long as its registry entry lives.
50
+ // Keying enter/exit on the presence TRANSITION (not the raw call) means a
51
+ // re-set of an already-present key, or a delete of an absent one, can never
52
+ // unbalance the count. Both the fetch (pull) and stream (push) lane route their
53
+ // registry mutations through here, so the push lane is counted correctly even
54
+ // though its DOM pending markers clear by a node swap, not clearDeferPending.
55
+ function setPendingDefer(targetId, entry) {
56
+ const isNew = !pendingDefers.has(targetId)
57
+ pendingDefers.set(targetId, entry)
58
+ if (isNew) shared.enter()
59
+ }
60
+
61
+ function deletePendingDefer(targetId) {
62
+ if (pendingDefers.delete(targetId)) shared.exit()
63
+ }
64
+
65
+ // Test seam: clear the module-level registry between unit tests. Also resets
66
+ // the one-shot settle-listener guard so a test's fresh document re-registers
67
+ // the turbo:before-stream-render settler. Does NOT touch the activity counter —
68
+ // resetReactiveActivity is its own seam (the two are reset together in tests).
69
+ export function resetReactiveDefers() {
70
+ pendingDefers.clear()
71
+ deferStreamSettleRegistered = false
72
+ }
73
+
74
+ // Test seam: the `via` of a target's pending defer entry (or undefined) — lets
75
+ // tests assert an entry was SETTLED (dropped) on arrival without exposing the
76
+ // Map. Not used by the runtime.
77
+ export function pendingDeferVia(targetId) {
78
+ return pendingDefers.get(targetId)?.via
79
+ }
80
+
81
+ let deferStreamSettleRegistered = false
82
+
83
+ // The body of the `reactive:defer` Turbo stream action. The core registers the
84
+ // action itself (a stream can arrive on a page that has not loaded this module
85
+ // yet) and hands each <turbo-stream> element over once the module is here —
86
+ // its attributes outlive Turbo removing the element from the document.
87
+ export function streamAction(streamEl) {
88
+ registerSettleOnRender()
89
+ const target = streamEl.getAttribute("target")
90
+ if (!target) return
91
+ if (streamEl.getAttribute("data-reactive-defer-via") === "stream") {
92
+ startStreamDefer(target, streamEl)
93
+ return
94
+ }
95
+ const token = streamEl.getAttribute("data-reactive-defer-token")
96
+ if (!token) return
97
+ startFetchDefer(target, token)
98
+ }
99
+
100
+ // Settle a STREAM-lane pendingDefers entry when its arrival lands: the job's
101
+ // broadcast is a turbo-stream that replaces the target and then removes the
102
+ // source. A document-level turbo:before-stream-render hook drops the Map
103
+ // entry the moment that removal renders — so the entry never outlives the
104
+ // delivery (the fetch lane settles inline; the stream lane's arrival is a
105
+ // broadcast nothing here awaits, so it needs this hook). Registered once; a
106
+ // no-op without document.
107
+ function registerSettleOnRender() {
108
+ if (deferStreamSettleRegistered || typeof document === "undefined" || !document.addEventListener) return
109
+ deferStreamSettleRegistered = true
110
+ // Capture phase: seen before any listener can stop it (renderStreamsNow).
111
+ document.addEventListener("turbo:before-stream-render", catchStream, true)
112
+ document.addEventListener("turbo:before-stream-render", settleStreamDeferOnRender)
113
+ }
114
+
115
+ // Drop a stream-lane pendingDefers entry when a turbo-stream targets its
116
+ // source element, reactive-defer-src-<target>: only the job's broadcast does
117
+ // (every payload, render or cleanup, ends with that removal). A stream to the
118
+ // target ITSELF settles nothing (issue #292): an unrelated update of #<target>
119
+ // between the enqueue and the job's broadcast is not the delivery. Pure Map
120
+ // cleanup — the DOM apply is Turbo's; this only releases our bookkeeping.
121
+ function settleStreamDeferOnRender(event) {
122
+ const target = event.target?.getAttribute?.("target")
123
+ if (!target?.startsWith("reactive-defer-src-")) return
124
+ const targetId = target.slice("reactive-defer-src-".length)
125
+ if (pendingDefers.get(targetId)?.via === "stream") deletePendingDefer(targetId)
126
+ }
127
+
128
+ // The pull lane: mark the target pending and fetch the real render, in
129
+ // parallel with everything else the page is doing. `source` is what to fetch:
130
+ // a signed defer token (a string — POSTed to the defer endpoint) or `{ src }`,
131
+ // a `reactive_lazy cache:` component's stable fragment URL (issue #277) —
132
+ // fetched with a plain GET so the browser's private HTTP cache can answer it.
133
+ // Returns the fetch's promise (it never rejects), or undefined when skipped.
134
+ function startFetchDefer(targetId, source) {
135
+ const el = document.getElementById(targetId)
136
+ if (!el) {
137
+ console.warn(`[phlex-reactive] reactive:defer target #${targetId} is not on the page — skipped`)
138
+ return
139
+ }
140
+ supersedeDefer(targetId)
141
+ markDeferPending(el)
142
+ const entry = { via: "fetch", abort: new AbortController(), timedOut: false }
143
+ setPendingDefer(targetId, entry)
144
+ return performDeferFetch(targetId, entry, source)
145
+ }
146
+
147
+ // The fetch() arguments for a pull-lane source. A fragment URL is a GET with
148
+ // no CSRF token and no body: rendering has no side effects, and anything that
149
+ // varied per request would defeat the cache (the response is keyed on the URL,
150
+ // Vary: Cookie). The default cache mode is what we want — reuse a fresh copy,
151
+ // revalidate a stale one with its ETag. A fallback GET (see
152
+ // fallbackFragmentSource) is the exception: the server never issued that URL,
153
+ // so its reply is kept out of the HTTP cache.
154
+ function deferRequest(source, signal) {
155
+ if (typeof source !== "string") {
156
+ const init = { headers: { Accept: "text/vnd.turbo-stream.html" }, credentials: "same-origin", signal }
157
+ if (source.fallback) init.cache = "no-store"
158
+ return [source.src, init]
159
+ }
160
+ return [
161
+ deferPath(),
162
+ {
163
+ method: "POST",
164
+ headers: {
165
+ Accept: "text/vnd.turbo-stream.html",
166
+ "Content-Type": "application/json",
167
+ "X-CSRF-Token": deferCsrfToken(),
168
+ },
169
+ body: JSON.stringify({ token: source }),
170
+ credentials: "same-origin",
171
+ signal,
172
+ },
173
+ ]
174
+ }
175
+
176
+ // A `cache:` shell's fragment URL as a pull-lane source, or undefined (no
177
+ // URL). Unlike a token (opaque, POSTed to one fixed path) this is a URL read
178
+ // from the DOM, and its response is rendered as a turbo-stream — so it is
179
+ // fetched ONLY when it resolves to this origin's fragment endpoint. Markup that
180
+ // smuggled the attribute in (user HTML that kept data-* attributes) must not be
181
+ // able to point the client at another origin, an upload, or any other
182
+ // same-origin path. A refused URL is logged and loads through the fallback GET
183
+ // below (issue #306); with no fallback the shell is failed, and this answers
184
+ // undefined.
185
+ function fragmentSource(el) {
186
+ const src = el.getAttribute?.("data-reactive-defer-src")
187
+ if (!src) return
188
+ if (isFragmentUrl(src)) return { src }
189
+ console.error(refusedFragmentMessage(src))
190
+ return fallbackFragmentSource(src) || failRefusedFragment(el)
191
+ }
192
+
193
+ function refusedFragmentMessage(src) {
194
+ return (
195
+ `[phlex-reactive] refused data-reactive-defer-src="${src}" — it is not under this app's fragment path ` +
196
+ `(${fragmentPath()}). If you changed Phlex::Reactive.fragment_path, add ` +
197
+ '<meta name="phlex-reactive-fragment-path" content="…your path…"> to the layout <head>.'
198
+ )
199
+ }
200
+
201
+ // The way a shell with a refused URL still loads (issue #306): a GET of the
202
+ // SAME signed id under the fragment path this client knows — so the request
203
+ // can only ever reach this app's own fragment endpoint, which verifies the id.
204
+ // It catches an app that renders absolute fragment URLs for another host
205
+ // name, or moved fragment_path without the meta (then the GET answers 404 and
206
+ // the shell fails with retry(), like any failed load). Undefined when the
207
+ // URL's last path segment cannot be a fragment id.
208
+ function fallbackFragmentSource(src) {
209
+ let url
210
+ try {
211
+ url = new URL(src, window.location.href)
212
+ } catch {
213
+ return
214
+ }
215
+ const id = url.pathname.split("/").pop()
216
+ const fallback = `${fragmentPath()}/${id}${url.search}`
217
+ // Held to the same check as any fragment URL, whatever the meta says.
218
+ if (/^[\w-]+$/.test(id) && isFragmentUrl(fallback)) return { src: fallback, fallback: true }
219
+ }
220
+
221
+ // A shell whose URL was refused and has no fallback has no other way to load,
222
+ // so it must not shimmer forever: clear pending, mark the root, and emit the same
223
+ // bubbling reactive:error a failed load does (no retry() — the URL won't change).
224
+ // It supersedes whatever load is in flight for this root first (issue #293):
225
+ // that load was for the shell this morph replaced, and its late arrival must
226
+ // neither paint over this one nor clear its error marker.
227
+ function failRefusedFragment(el) {
228
+ supersedeDefer(el.id)
229
+ clearDeferPending(el)
230
+ el.setAttribute("data-reactive-error", "defer")
231
+ el.dispatchEvent(
232
+ new CustomEvent("reactive:error", {
233
+ bubbles: true,
234
+ composed: true,
235
+ detail: { kind: "defer", target: el.id, reason: "refused-url" },
236
+ }),
237
+ )
238
+ }
239
+
240
+ function isFragmentUrl(src) {
241
+ try {
242
+ const here = new URL(window.location.href)
243
+ const url = new URL(src, here)
244
+ return url.origin === here.origin && url.pathname.startsWith(`${fragmentPath()}/`)
245
+ } catch {
246
+ return false
247
+ }
248
+ }
249
+
250
+ // Phlex::Reactive.fragment_path, for the check above. An app that moves the
251
+ // endpoint renders <meta name="phlex-reactive-fragment-path"> (as for the
252
+ // action and defer paths); the URL itself always comes from the shell. Read
253
+ // from <head> ONLY: this meta widens what the client will fetch and render, so
254
+ // one injected into the body must not count.
255
+ function fragmentPath() {
256
+ return document.head?.querySelector?.('meta[name="phlex-reactive-fragment-path"]')?.content || "/reactive/fragment"
257
+ }
258
+
259
+ // The push lane: subscribe a <pgbus-stream-source> to the server-signed
260
+ // one-shot stream. Arrival + teardown need no client logic — the job's
261
+ // broadcast carries the replace AND a remove of this source element (its
262
+ // disconnectedCallback closes the SSE connection). since-id=0 on a fresh key
263
+ // replays a broadcast that beat the subscription (the durable-lane guarantee).
264
+ function startStreamDefer(targetId, directive) {
265
+ const el = document.getElementById(targetId)
266
+ if (!el) {
267
+ console.warn(`[phlex-reactive] reactive:defer target #${targetId} is not on the page — skipped`)
268
+ return
269
+ }
270
+ const src = directive.getAttribute("data-reactive-defer-src")
271
+ if (!src) return
272
+ if (!globalThis.customElements?.get?.("pgbus-stream-source")) {
273
+ // The server chose push on server-side capability, but this page has no
274
+ // pgbus client. Degrade to the fetch lane using the fallback token the push
275
+ // directive carries — rather than dead-end the shimmer. No token (an app on
276
+ // a bespoke transport) is a loud no-op.
277
+ const fallbackToken = directive.getAttribute("data-reactive-defer-token")
278
+ if (fallbackToken) {
279
+ startFetchDefer(targetId, fallbackToken)
280
+ return
281
+ }
282
+ console.error(
283
+ "[phlex-reactive] reactive:defer via=stream but <pgbus-stream-source> is not registered " +
284
+ "and no fallback token was provided — is the pgbus client loaded on this page?",
285
+ )
286
+ return
287
+ }
288
+ supersedeDefer(targetId)
289
+ markDeferPending(el)
290
+ const source = document.createElement("pgbus-stream-source")
291
+ // Deterministic id: the JOB's broadcast removes it by this exact id — the
292
+ // subscription tears itself down with the payload it delivered.
293
+ source.id = deferSourceId(targetId)
294
+ source.setAttribute("src", src)
295
+ source.setAttribute("since-id", directive.getAttribute("data-reactive-defer-since-id") ?? "0")
296
+ source.setAttribute("hidden", "")
297
+ document.body.appendChild(source)
298
+ // Record only { via } — NOT a strong ref to the source element. The job's
299
+ // broadcast removes the source by its deterministic id (its
300
+ // disconnectedCallback closes the SSE), so holding srcEl here would pin the
301
+ // detached node in this module-level Map forever (a leak). Supersession
302
+ // re-finds the element by id instead. The entry is dropped on
303
+ // supersession or when the arriving broadcast removes the source (a
304
+ // turbo:before-stream-render hook, below).
305
+ setPendingDefer(targetId, { via: "stream" })
306
+ }
307
+
308
+ // The deterministic id of a target's one-shot <pgbus-stream-source>.
309
+ function deferSourceId(targetId) {
310
+ return `reactive-defer-src-${targetId}`
311
+ }
312
+
313
+ async function performDeferFetch(targetId, entry, source) {
314
+ // Bound the wait like the action fetch (issue #101) — a hung defer must not
315
+ // shimmer forever. A manual timer (not AbortSignal.timeout) so the catch can
316
+ // tell a TIMEOUT (fail loudly) from a SUPERSEDED abort (stay silent).
317
+ const timer = setTimeout(() => {
318
+ entry.timedOut = true
319
+ entry.abort.abort()
320
+ }, deferTimeoutMs())
321
+
322
+ // The timeout is cleared ONLY after the body is fully read (below), not the
323
+ // moment headers arrive — a server that streams headers then stalls the body
324
+ // must still abort, or the shimmer hangs forever (the abort signal covers the
325
+ // whole fetch + body read, mirroring #perform's AbortSignal.timeout).
326
+ let response
327
+ try {
328
+ // Counted per fetch() CALL — a fragment GET the browser answers from its
329
+ // HTTP cache still counts (it is a request the client issued; whether it
330
+ // touched the network is Resource Timing's transferSize).
331
+ shared.count("defer")
332
+ response = await fetch(...deferRequest(source, entry.abort.signal))
333
+ } catch (error) {
334
+ clearTimeout(timer)
335
+ if (pendingDefers.get(targetId) !== entry) return // superseded — silent
336
+ console.error("[phlex-reactive] deferred render failed", error)
337
+ failDefer(targetId, source)
338
+ return
339
+ }
340
+ if (pendingDefers.get(targetId) !== entry) {
341
+ clearTimeout(timer)
342
+ return // superseded mid-flight
343
+ }
344
+
345
+ // The body is about to be rendered as a turbo-stream, so it must BE one, from
346
+ // the URL we asked for. A base-controller filter that redirects to a sign-in
347
+ // page ends in a 200 HTML document — a failed load, never rendered.
348
+ if (response.redirected) {
349
+ clearTimeout(timer)
350
+ console.error(`[phlex-reactive] deferred render failed: redirected to ${response.url ?? "another URL"}`)
351
+ failDefer(targetId, source, response.status)
352
+ return
353
+ }
354
+
355
+ if (response.status === 204) {
356
+ clearTimeout(timer)
357
+ // render? false — keep the current content, just clear the pending state.
358
+ settleDefer(targetId)
359
+ return
360
+ }
361
+ if (!response.ok) {
362
+ clearTimeout(timer)
363
+ console.error(`[phlex-reactive] deferred render failed: HTTP ${response.status}`)
364
+ failDefer(targetId, source, response.status)
365
+ return
366
+ }
367
+ const contentType = response.headers?.get?.("Content-Type") || ""
368
+ if (!contentType.includes("turbo-stream")) {
369
+ clearTimeout(timer)
370
+ console.error(`[phlex-reactive] deferred render failed: expected a turbo-stream, got "${contentType}"`)
371
+ failDefer(targetId, source, response.status)
372
+ return
373
+ }
374
+
375
+ let html
376
+ try {
377
+ html = await response.text()
378
+ } catch (error) {
379
+ clearTimeout(timer)
380
+ if (pendingDefers.get(targetId) !== entry) return
381
+ console.error("[phlex-reactive] deferred render failed reading the body", error)
382
+ failDefer(targetId, source)
383
+ return
384
+ }
385
+ clearTimeout(timer)
386
+ if (pendingDefers.get(targetId) !== entry) return // superseded during read
387
+
388
+ settleDefer(targetId)
389
+ // A normal replace/morph of the target — the fresh root carries no pending
390
+ // markers and a fresh action token, so the component lands interactive.
391
+ renderStreamsNow(html)
392
+ }
393
+
394
+ // Render a reply's streams in this task, not a frame from now (issue #336).
395
+ // renderStreamMessage appends the <turbo-stream>s; each one dispatches
396
+ // turbo:before-stream-render as it connects, then awaits nextRepaint() before
397
+ // calling event.detail.render(this). The events are caught here, and once
398
+ // every listener has had its say (dispatch is synchronous) the final
399
+ // detail.render runs now, in order, unless the event was cancelled; Turbo's
400
+ // frame-later call gets a no-op. Everything else — the event, the parse,
401
+ // scripts, permanent elements, autofocus — stays Turbo's. A Turbo that
402
+ // dispatches later is never caught, and renders on its own as before.
403
+ // The catching listener is registerSettleOnRender's (every fetch starts after
404
+ // it ran).
405
+ let caughtStreams = null
406
+ const catchStream = (event) => caughtStreams?.push(event)
407
+
408
+ function renderStreamsNow(html) {
409
+ const caught = (caughtStreams = [])
410
+ try {
411
+ window.Turbo.renderStreamMessage(html)
412
+ } finally {
413
+ caughtStreams = null
414
+ }
415
+ for (const event of caught) {
416
+ if (event.defaultPrevented) continue
417
+ const detail = event.detail
418
+ const render = detail.render
419
+ detail.render = () => {}
420
+ // A throwing render is logged, as Turbo's connectedCallback does, and the
421
+ // next stream still applies.
422
+ new Promise((resolve) => resolve(render(detail.newStream))).catch(console.error)
423
+ }
424
+ }
425
+
426
+ // Abort/unsubscribe whatever delivery is in flight for this target. The
427
+ // deleted entry makes every late arrival fail its identity check — stale
428
+ // content can never paint.
429
+ function supersedeDefer(targetId) {
430
+ const existing = pendingDefers.get(targetId)
431
+ if (!existing) return
432
+ deletePendingDefer(targetId)
433
+ if (existing.via === "fetch") existing.abort.abort()
434
+ // Stream lane: re-find the old source by its deterministic id and remove it
435
+ // (unsubscribe) — we deliberately don't hold a strong ref to the detached
436
+ // node. Its disconnectedCallback closes the SSE.
437
+ else document.getElementById(deferSourceId(targetId))?.remove?.()
438
+ }
439
+
440
+ function markDeferPending(el) {
441
+ el.setAttribute("data-reactive-defer-pending", "true")
442
+ el.setAttribute("aria-busy", "true")
443
+ }
444
+
445
+ function clearDeferPending(el) {
446
+ el.removeAttribute("data-reactive-defer-pending")
447
+ el.removeAttribute("aria-busy")
448
+ }
449
+
450
+ // Success/204: drop the registry entry, clear pending, and clear any prior
451
+ // defer failure marker (recovery resets error-driven CSS, issue #100 style).
452
+ function settleDefer(targetId) {
453
+ deletePendingDefer(targetId)
454
+ const el = document.getElementById(targetId)
455
+ if (!el) return
456
+ clearDeferPending(el)
457
+ el.removeAttribute("data-reactive-error")
458
+ }
459
+
460
+ // Failure: clear pending (the shimmer must not lie), mark the root
461
+ // (data-reactive-error="defer" — style it in pure CSS), and emit a bubbling
462
+ // reactive:error whose retry() re-enters the defer fetch with the SAME source
463
+ // (a token is still valid inside the TTL; an expired one 400s into this same
464
+ // path. A fragment URL never expires).
465
+ function failDefer(targetId, source, status) {
466
+ deletePendingDefer(targetId)
467
+ const el = document.getElementById(targetId)
468
+ if (!el) return
469
+ clearDeferPending(el)
470
+ el.setAttribute("data-reactive-error", "defer")
471
+ const retry = () => {
472
+ const fresh = document.getElementById(targetId)
473
+ if (!fresh) {
474
+ console.warn("[phlex-reactive] defer retry() ignored — the target left the DOM")
475
+ return
476
+ }
477
+ fresh.removeAttribute("data-reactive-error")
478
+ startFetchDefer(targetId, source)
479
+ }
480
+ el.dispatchEvent(
481
+ new CustomEvent("reactive:error", {
482
+ bubbles: true,
483
+ composed: true,
484
+ detail: { kind: "defer", target: targetId, status, retry },
485
+ }),
486
+ )
487
+ }
488
+
489
+ function deferPath() {
490
+ return document.querySelector('meta[name="phlex-reactive-defer-path"]')?.content || "/reactive/defer"
491
+ }
492
+
493
+ // CSRF is read LIVE per request (Rails can rotate it) — same contract as the
494
+ // controller's #csrfToken.
495
+ function deferCsrfToken() {
496
+ return document.querySelector('meta[name="csrf-token"]')?.content ?? ""
497
+ }
498
+
499
+ // Same page-stable meta + default as the controller's #timeoutMs (issue #101),
500
+ // parsed defensively so a typo'd meta can never disable the bound.
501
+ function deferTimeoutMs() {
502
+ const raw = document.querySelector('meta[name="phlex-reactive-timeout"]')?.content
503
+ const ms = Number(raw)
504
+ return Number.isFinite(ms) && ms > 0 ? ms : 30000
505
+ }
506
+
507
+ // --- Per-root wiring -------------------------------------------------------
508
+
509
+ export const LAZY_MATERIALIZE_ACTION = "__materialize"
510
+
511
+ // controller -> this connection's { load, off }: its materialize and its
512
+ // teardown, closures over the connection's lazy state (locals of connect(),
513
+ // not a record's fields — the minifier renames a local, never a property,
514
+ // issue #310):
515
+ // wasShell whether the root was a trigger shell when last looked at
516
+ // (connect, then every morph) — a morph that turns REAL content
517
+ // back into a shell re-materializes; one that leaves a shell a
518
+ // shell only re-arms it.
519
+ // inFlight the ONE dedupe point every materialize passes (the Stimulus
520
+ // binding, the observer, the re-armed listener, a morph-back).
521
+ // observer, eventName, onProbe, onMorph held for teardown.
522
+ const wired = new WeakMap()
523
+
524
+ // Which reactive_lazy(on:) shell this root currently is — read live, because
525
+ // a morph rewrites the attributes on a connected element. null = real content
526
+ // (or a fetch-on-connect shell).
527
+ function shellKind(el) {
528
+ if (el.getAttribute?.("data-reactive-lazy-visible") != null) return "visible"
529
+ if (el.getAttribute?.("data-reactive-lazy-on")) return "event"
530
+ return null
531
+ }
532
+
533
+ // `morphed` is true when this connect follows a MORPH of an already connected
534
+ // root (the core re-scans for feature markers on a morph of the root): the
535
+ // root showed real content and the morph turned it into a shell.
536
+ export function connect(controller, core, morphed) {
537
+ registerSettleOnRender()
538
+ const el = controller.element
539
+ let wasShell = false
540
+ let inFlight = false
541
+ let observer = null
542
+ let eventName = null
543
+ let onProbe
544
+ let onMorph
545
+ // Every trigger goes through the exported materialize, so one that fires
546
+ // after disconnect (a queued microtask) finds nothing wired and does nothing.
547
+ const onEvent = () => materialize(controller)
548
+
549
+ // Arm the shell's trigger. A :visible shell gets (at most one) observer. An
550
+ // event shell's FIRST event rides its Stimulus `once` binding, so on connect
551
+ // there is nothing to add; after a morph (`rearm`) that binding may be spent
552
+ // — Stimulus only re-binds when the descriptor attribute itself changed — so
553
+ // this listens for the event itself. Both can fire for one event; materialize
554
+ // dedupes. Either trigger is consumed when a request starts (like `once`):
555
+ // one morph buys one attempt.
556
+ const arm = (rearm) => {
557
+ if (shellKind(el) === "visible") {
558
+ if (!observer) observeVisible()
559
+ } else if (rearm) {
560
+ const name = el.getAttribute("data-reactive-lazy-on")
561
+ if (eventName === name) return
562
+ disarm()
563
+ el.addEventListener?.(name, onEvent)
564
+ eventName = name
565
+ }
566
+ }
567
+
568
+ const disarm = () => {
569
+ observer?.disconnect()
570
+ observer = null
571
+ if (eventName) el.removeEventListener?.(eventName, onEvent)
572
+ eventName = null
573
+ }
574
+
575
+ // reactive_lazy(on: :visible) (issue #276): materialize the first time the
576
+ // shell intersects the viewport (grown or shrunk by the rendered
577
+ // rootMargin). Without IntersectionObserver (very old engines) materialize
578
+ // right away: the content still loads, just not lazily.
579
+ const observeVisible = () => {
580
+ if (typeof IntersectionObserver === "undefined") return queueMicrotask(onEvent)
581
+ const rootMargin = el.getAttribute("data-reactive-lazy-visible") || "0px"
582
+ observer = new IntersectionObserver(
583
+ (entries) => {
584
+ if (entries.some((entry) => entry.isIntersecting)) onEvent()
585
+ },
586
+ { rootMargin },
587
+ )
588
+ observer.observe(el)
589
+ }
590
+
591
+ // turbo:morph-element OF the root: the morph is server truth arriving on a
592
+ // CONNECTED element. Four outcomes:
593
+ // * now real content → nothing to load; drop any armed trigger.
594
+ // * a load is in flight → leave it; its reply replaces the shell.
595
+ // * was real, now a shell → it was loaded and the morph wiped it, so
596
+ // re-materialize now (for an event shell
597
+ // the event — a panel opening — already
598
+ // happened and won't fire again).
599
+ // * was a shell, still a shell → never triggered, or a failed load: re-arm
600
+ // it (this is a failed load's retry path).
601
+ const afterMorph = () => {
602
+ const shell = shellKind(el) !== null
603
+ const was = wasShell
604
+ wasShell = shell
605
+ if (!shell) return disarm()
606
+ if (inFlight) return
607
+ // The morphed-in shell's token is the page's current identity; a token
608
+ // cached from an earlier reply would materialize stale state.
609
+ core.forgetToken()
610
+ if (!was) return onEvent()
611
+ arm(true)
612
+ }
613
+
614
+ wired.set(controller, {
615
+ // See materialize() below.
616
+ load() {
617
+ if (inFlight) return
618
+ const source = fragmentSource(el)
619
+ // A URL refused with no fallback has already failed the shell: never POST.
620
+ if (!source && el.getAttribute("data-reactive-defer-src") != null) return
621
+ // The GET skips the action pipeline, so raise its veto here: an app's
622
+ // reactive:before-dispatch listener controls a materialize either way.
623
+ if (source && materializeVetoed(el, core)) return
624
+ const run = source ? startFetchDefer(el.id, source) : core.proceed(el, LAZY_MATERIALIZE_ACTION, "{}")
625
+ if (!run) return // vetoed by reactive:before-dispatch (or the root has no id)
626
+ inFlight = true
627
+ // Consume the armed trigger (observer or re-armed listener): a failed load
628
+ // is retried by the NEXT morph, not by every later event.
629
+ disarm()
630
+ const done = () => {
631
+ inFlight = false
632
+ }
633
+ run.then(done, done)
634
+ return run
635
+ },
636
+ off() {
637
+ if (onProbe) el.removeEventListener?.("turbo:morph-element", onProbe)
638
+ if (onMorph) el.removeEventListener?.("turbo:morph-element", onMorph)
639
+ disarm()
640
+ },
641
+ })
642
+
643
+ // Lazy initial mount (issue #165): a reactive_lazy shell carries its defer
644
+ // token as a ROOT attribute — enter the SAME module-level fetch path a
645
+ // reply directive uses (supersession, pending markers, error handling
646
+ // included). Probe on connect (a plain replace / cache restoration
647
+ // re-connects) AND on turbo:morph-element: a Turbo page-refresh MORPH
648
+ // re-shows the shell while keeping the element CONNECTED and firing no
649
+ // Stimulus lifecycle, so a connect-only probe would leave the morphed-in
650
+ // shell shimmering forever. turbo:morph-element BUBBLES, and a re-probe
651
+ // supersedes (aborts and re-issues) the fetch in flight, so only a morph of
652
+ // the root itself counts (issue #294). The attribute stays on the shell
653
+ // precisely so a re-appearance re-fires.
654
+ // A `cache:` shell (issue #277) carries a fragment URL instead of the token
655
+ // and takes the same path; an on: shell with a URL is NOT probed — it waits
656
+ // for its trigger (its root has no pending marker).
657
+ const fetches = el.getAttribute?.("data-reactive-defer-token") || el.getAttribute?.("data-reactive-defer-src")
658
+ if (fetches && shellKind(el) === null) {
659
+ probe(el)
660
+ onProbe = (event) => event.target === el && probe(el)
661
+ el.addEventListener?.("turbo:morph-element", onProbe)
662
+ }
663
+
664
+ // reactive_lazy(on:) shells (issue #276) carry NO defer token, so the probe
665
+ // above skips them: an event shell waits for its once-bound __materialize
666
+ // trigger, a :visible shell for the observer armed here. An on: shell
667
+ // carries either the identity token or (with cache:, issue #306) its
668
+ // fragment URL; a root with neither is client-only, never an on: shell.
669
+ const tokenless = el.getAttribute?.("data-reactive-token-value") == null
670
+ if (tokenless && el.getAttribute?.("data-reactive-defer-src") == null) return
671
+ // turbo:morph-element BUBBLES: only a morph of the root itself counts, never
672
+ // one of a descendant (a morphed skeleton child, a nested root). The same
673
+ // morph can turn real content into a FETCH-ON-CONNECT shell (plain
674
+ // reactive_lazy, or cache: without on:): probe it too, unless this root
675
+ // connected as such a shell and already re-probes on every morph.
676
+ onMorph = (event) => {
677
+ if (event.target !== el) return
678
+ if (!onProbe) probe(el)
679
+ afterMorph()
680
+ }
681
+ el.addEventListener?.("turbo:morph-element", onMorph)
682
+ if (morphed) return afterMorph()
683
+ wasShell = shellKind(el) !== null
684
+ if (wasShell) arm(false)
685
+ }
686
+
687
+ export function disconnect(controller) {
688
+ const wiring = wired.get(controller)
689
+ wired.delete(controller)
690
+ wiring?.off()
691
+ }
692
+
693
+ // THE materialize entry point: the shell's Stimulus binding (the core's
694
+ // dispatch routes its __materialize action here), the :visible observer, the
695
+ // re-armed event listener and a morph-back all land here, so one in-flight
696
+ // flag makes "exactly one request" hold no matter how many of them fire. It
697
+ // rides the ordinary action pipeline (core.proceed → the serialized queue →
698
+ // the request): the reactive:before-dispatch veto, busy markers, error
699
+ // marker/events and the request counter all apply.
700
+ //
701
+ // A `cache:` shell (issue #277) carries its fragment URL: the load is then a
702
+ // GET on the defer pull lane (pending markers, timeout, reactive:error with
703
+ // retry()) instead of the action POST, so the browser's cache can answer —
704
+ // on the first trigger of a later page view, and on every morph-back.
705
+ export function materialize(controller) {
706
+ return wired.get(controller)?.load()
707
+ }
708
+
709
+ function materializeVetoed(el, core) {
710
+ return core.emit("reactive:before-dispatch", { action: LAZY_MATERIALIZE_ACTION, params: {}, element: el }, { cancelable: true })
711
+ .defaultPrevented
712
+ }
713
+
714
+ // Lazy initial mount probe (issue #165): fetch the real content when THIS
715
+ // root is a reactive_lazy shell that still carries its defer token AND the
716
+ // pending marker. Gating on the pending marker is what makes a re-probe (a
717
+ // Turbo morph re-showing the shell) fire while a re-probe of an already
718
+ // RESOLVED root (real content, no token, no marker) is a no-op. The
719
+ // module-level supersession registry dedupes a duplicate in-flight fetch for
720
+ // the same id, so calling this on both connect and every morph is safe.
721
+ function probe(el) {
722
+ if (!el?.id) return
723
+ if (el.getAttribute?.("data-reactive-defer-pending") !== "true") return
724
+ const source = el.getAttribute?.("data-reactive-defer-token") || fragmentSource(el)
725
+ if (source) startFetchDefer(el.id, source)
726
+ }