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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1079 -630
- data/README.md +612 -17
- data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
- data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
- data/app/javascript/phlex/reactive/core.js +181 -0
- data/app/javascript/phlex/reactive/core.min.js +4 -0
- data/app/javascript/phlex/reactive/core.min.js.map +11 -0
- data/app/javascript/phlex/reactive/early.js +175 -0
- data/app/javascript/phlex/reactive/early.min.js +4 -0
- data/app/javascript/phlex/reactive/early.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/bindings.js +1582 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/compute.js +541 -0
- data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/defer.js +726 -0
- data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
- data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/devtools.js +242 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/effects.js +362 -0
- data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
- data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/form.js +216 -0
- data/app/javascript/phlex/reactive/features/form.min.js +4 -0
- data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/hints.js +193 -0
- data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
- data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/persist.js +663 -0
- data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
- data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +86 -5821
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
- data/app/javascript/phlex/reactive/runtime.js +3706 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
- data/lib/phlex/reactive/component/dsl.rb +74 -2
- data/lib/phlex/reactive/component/helpers.rb +39 -9
- data/lib/phlex/reactive/component/lazy.rb +138 -2
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/component/selection.rb +188 -0
- data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
- data/lib/phlex/reactive/doctor.rb +166 -8
- data/lib/phlex/reactive/dormant.rb +66 -0
- data/lib/phlex/reactive/engine.rb +63 -3
- data/lib/phlex/reactive/fragment.rb +233 -0
- data/lib/phlex/reactive/inspector/report.rb +24 -3
- data/lib/phlex/reactive/inspector.rb +23 -2
- data/lib/phlex/reactive/js.rb +47 -2
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
- data/lib/phlex/reactive/param_schema.rb +68 -54
- data/lib/phlex/reactive/show_conditions.rb +84 -56
- data/lib/phlex/reactive/streamable.rb +27 -7
- data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
- data/lib/phlex/reactive/test_helpers/system.rb +62 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +84 -0
- data/lib/tasks/phlex_reactive.rake +6 -2
- 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
|
+
}
|