phlex-reactive 0.12.4 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 735a21537bb98a3b89ba46f21fe40630ee413191e5509db2fe7895172e6b92b7
4
- data.tar.gz: 2110972a68b3ff0c336255afe15674167d9012201ebe80479a5c84fff922e682
3
+ metadata.gz: ec14089cc43f0bf439305f48b9fca3ee8b0fe2ba83b44b341e22370ad11fc22d
4
+ data.tar.gz: 101ae52fce38a343185a2699082c79709dadf609d9401f199b9b9c5f181dda9c
5
5
  SHA512:
6
- metadata.gz: 7f6c98822ac5eabbcfdd39b6bdbbbd50176e6d4d5735b5eb0189321ac26e0b559ccc7f8ec60c9394492e23e53eb7e77b79c89c0298f1c962b2115961f8cbfc48
7
- data.tar.gz: 4b7dc6d779d5f250592387b3cb9615b4d9d37a5994c0b03e3b83ad1e740251e9875b06735b79a02e304ba0fe53b659220950c47906d0805d0128e81b84231966
6
+ metadata.gz: '083b640658e403acc1bfeb311d0c22051cd3a2b275a4f44084dfb9c52755fab481eb63c7458e593eb43741d6b3726b8c8e7dadeb2059bcd9b9818cae64768937'
7
+ data.tar.gz: 3984b48e58eb838ed9bfb009d50444babd518831eb5d2fc7c0f77dc577060b146729f428e7bf1acead2281dd86547fc86130c43ba245b6a4956acf0d5e39d04f
data/CHANGELOG.md CHANGED
@@ -8,6 +8,23 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **Dev-mode warning when a client op resolves zero targets (#237).** A client
12
+ op that matches nothing is indistinguishable from a working no-op, and the
13
+ three documented scoping traps — the nested-root ownership filter, a selector
14
+ that matches the component's own root (root-scoped resolution never includes
15
+ the root itself), and the op stream's default target scope — all present
16
+ exactly that way. Now `verbose_errors` (on by default in dev/test) reaches
17
+ the client as `data-reactive-verbose="true"` on reactive roots and on
18
+ server-emitted `reactive:js` streams, and under that gate (or full
19
+ `debug` mode) the interpreter `console.warn`s once per unique
20
+ (op, selector, scope) naming the op, the selector, and the scoping root —
21
+ plus the documented escape (`to: :root` / `global: true`) as a hint when the
22
+ element exists in the DOM but sits outside the op's scope. Covers `on_client`
23
+ ops, reducer `$ops`, `reply…js(...)` / `broadcast_js_to` streams (including a
24
+ stream whose `target` root id has left the DOM), and busy/optimistic hint
25
+ targets. Production (flag off) renders no attribute and stays byte-identical
26
+ and silent; resolution behavior itself is unchanged everywhere.
27
+
11
28
  - **`js.paste_into(selector)` — the clipboard-source trigger (#228).** A field
12
29
  whose real `<input>` is visually hidden (an OTP cell UI painted by a
13
30
  `reactive_compute` reducer) has no mouse-reachable paste path: right-click →
@@ -315,6 +332,53 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
315
332
 
316
333
  ### Fixed
317
334
 
335
+ - **`Component::Action` renamed `ActionDefinition` — it shadowed a host kit
336
+ component named `Action` under dev autoloading (#233).** The mixin sits in
337
+ every reactive component's ancestry, so its bare `Action` constant satisfied
338
+ Phlex::Kit's LazyLoader `const_get` check with the wrong class: the kit's
339
+ real `Action` component never autoloaded and the call fell through to
340
+ `NoMethodError` — intermittently (any unrelated load of the kit class masks
341
+ it until the next reload) and only under lazy autoloading, so an
342
+ eager-loaded test suite stays green. The rename matches its siblings
343
+ (`ComputeDefinition`, `OnCompleteDefinition`, `CollectionDefinition`) and
344
+ removes the constant entirely — **deliberately no alias**, since any
345
+ `Action` constant in the ancestry re-creates the shadowing. Breaking only
346
+ for code referencing the internal
347
+ `Phlex::Reactive::Component::Action` Data class directly; the `action :name`
348
+ DSL and `reactive_actions` registry are unchanged. A regression spec guards
349
+ the whole component ancestry against `:Action` coming back.
350
+
351
+ - **Multipart path corrupted/dropped fields with Rails-bracketed names (#231).**
352
+ `#buildFormData` wrapped every collected field/file name verbatim as
353
+ `params[<name>]`, so `blog_post[summary]` went out as the Rack-unparseable
354
+ `params[blog_post[summary]]` — the scalar arrived as `{"]" => value}` (and a
355
+ writer stringified that hash into the record, with a 200), and a `:file` under
356
+ a bracketed name (`blog_post[image]`) never reached its schema key (silent
357
+ drop). It only fired when a file input was populated — the JSON path expands
358
+ the same names server-side — which is why flat-named forms never caught it.
359
+ The client now bracket-expands the field **name** into wire segments
360
+ (`params[blog_post][summary]`, `params[blog_post][image]`, and
361
+ `params[photos][]` for a `multiple` picker named `photos[]`), mirroring the
362
+ server's `bracket_path` exactly, so a multipart body and a JSON body coerce
363
+ identically for the same fields. Flat names are byte-identical to before.
364
+
365
+ - **Reply-rendered components now generate absolute URLs with the REQUESTING
366
+ host, not the process default (#232).** Actor replies (`reply.replace`,
367
+ `reply.streams(...)`, the implicit replace) render through the memoized
368
+ off-request view context, whose `url_options` were process defaults — so any
369
+ absolute URL helper (`image_tag` on an Active Storage attachment being the
370
+ everyday case) rendered the wrong host on a multi-host app: a broken image
371
+ after every save, healed by the next full page load. The action endpoint now
372
+ threads the request's protocol/host/port into the reply render (the
373
+ `ActiveStorage::SetCurrent` move) via a thread-local the memoized context
374
+ merges per call — the context is never rebuilt, so the render memoization
375
+ win is untouched (allocations byte-identical, throughput flat within noise).
376
+ The defer **pull** endpoint gets the same treatment (it is an actor request).
377
+ **Broadcasts are unchanged by design** — there is no request and subscribers
378
+ can be on different hosts, so a broadcast fired *inside* an action explicitly
379
+ clears the actor's url_options around its render; "URLs in broadcast-rendered
380
+ components must be host-relative" is now documented in the Broadcasting guide.
381
+
318
382
  - **`rake bench` ran the removed `broadcast_replace_to` API and exited 1.**
319
383
  `benchmark/micro/broadcast.rb` migrated to the #185 spellings
320
384
  (`broadcast_to(*key, replace:)` / `broadcast_to(each:, replace:)`); the
data/README.md CHANGED
@@ -458,7 +458,10 @@ a populated `<input type="file">`, the client sends the action as multipart
458
458
  `FormData` (instead of JSON) — `token` + `act` as fields, scalar params as fields,
459
459
  any nested/array params bracket-expanded into `params[key][sub]` /
460
460
  `params[key][index]` fields (the same Rails-form shape, so a JSON body and a
461
- multipart body coerce identically — #39), and the file(s) appended; the endpoint
461
+ multipart body coerce identically — #39), and the file(s) appended. A
462
+ Rails-bracketed field **name** (`blog_post[summary]`, `blog_post[image]`)
463
+ expands the same way — `params[blog_post][summary]`, never the unparseable
464
+ `params[blog_post[summary]]` (#231). The endpoint
462
465
  coerces `:file` to the `ActionDispatch::Http::UploadedFile`, passed through
463
466
  untouched. A non-file value sent to a `:file` param is dropped (the keyword
464
467
  default applies — never a fabricated file). Token threading and the
@@ -822,6 +825,14 @@ field collection); `:root` targets the root element itself; `global: true` on
822
825
  an op escapes to the whole document. An op name the client doesn't recognize
823
826
  logs a warning and is skipped — the rest of the chain still applies.
824
827
 
828
+ In development and test (`verbose_errors`), an op whose selector resolves to
829
+ **zero elements** also warns once per unique (op, selector, scope) — with a
830
+ targeted hint when the element exists but sits outside the op's scope: "use
831
+ `to: :root`" when the selector matches the component's own root (root-scoped
832
+ resolution never includes the root itself), or "use `global: true`" when it
833
+ matches only inside a nested reactive root or elsewhere in the document.
834
+ Production stays silent — a zero-match no-op is legitimate there.
835
+
825
836
  **Attributes, focus, dispatch, and transitions.** Beyond visibility and classes,
826
837
  the same chain covers the rest of the client-only vocabulary:
827
838
 
@@ -2600,6 +2611,19 @@ helper — a child row rendering a trigger for its container's action) is skippe
2600
2611
  `on_client` triggers are never checked (they aren't declared actions). The
2601
2612
  server's default-deny stays the security boundary — this is a dev-time courtesy.
2602
2613
 
2614
+ The flag reaches the **client** too: reactive roots (and server-emitted
2615
+ `reactive:js` streams) carry `data-reactive-verbose="true"` when it is on, and
2616
+ the client op interpreter uses that gate to `console.warn` when a client op —
2617
+ `on_client`, a reducer's `$ops`, `reply…js(...)`, `broadcast_js_to`, or a
2618
+ busy/optimistic hint — resolves **zero targets**. The warning names the op, the
2619
+ selector, and the scoping root, dedupes per unique case per page, and hints the
2620
+ documented escape (`to: :root` / `global: true`) when the element exists but
2621
+ was filtered out by root scoping or the nested-root ownership rule — the trap
2622
+ where the element is right there in the DOM, just outside the op's scope. A
2623
+ `reactive:js` stream whose `target` root id has left the DOM warns the same
2624
+ way instead of silently dropping its ops. Production (flag off) renders no
2625
+ attribute and stays byte-identical and silent.
2626
+
2603
2627
  See [docs/security.md](https://phlex-reactive.zoolutions.llc/docs/security) for the threat model and a checklist.
2604
2628
 
2605
2629
  ---
@@ -39,9 +39,17 @@ module Phlex
39
39
  # #165 security), so the defer endpoint accepts them ONLY back from this
40
40
  # same actor. The defer token is built in response_streams (inside this
41
41
  # block via the Defer builder), so the binding must be established here.
42
- Phlex::Reactive.with_defer_binding(Phlex::Reactive.defer_binding_for(request)) do
43
- Phlex::Reactive.instrument("action", event) do
44
- create_action(event)
42
+ # Thread the ACTOR's url_options (protocol/host/port) into the reply
43
+ # render (issue #232): the reply renders through the memoized
44
+ # off-request view context, whose process-default url_options emit the
45
+ # wrong host for absolute URL helpers on a multi-host app. Broadcasts
46
+ # fired inside the action are exempted at their render (see
47
+ # Streamable.broadcast_component) — subscribers can be on other hosts.
48
+ Phlex::Reactive.with_url_options(Phlex::Reactive.url_options_for(request)) do
49
+ Phlex::Reactive.with_defer_binding(Phlex::Reactive.defer_binding_for(request)) do
50
+ Phlex::Reactive.instrument("action", event) do
51
+ create_action(event)
52
+ end
45
53
  end
46
54
  end
47
55
  end
@@ -62,9 +70,14 @@ module Phlex
62
70
  # so a leaked token can't be exchanged here for this actor's render (and
63
71
  # its embedded fresh identity token). Unbound requests (no session) are
64
72
  # unchanged.
65
- Phlex::Reactive.with_defer_binding(Phlex::Reactive.defer_binding_for(request)) do
66
- Phlex::Reactive.instrument("defer", event) do
67
- deferred_action(event)
73
+ # The defer PULL is an actor request too (issue #232) — its render gets
74
+ # the same request-derived url_options as the action reply. The PUSH
75
+ # lane (job → SSE) has no request and stays on process defaults.
76
+ Phlex::Reactive.with_url_options(Phlex::Reactive.url_options_for(request)) do
77
+ Phlex::Reactive.with_defer_binding(Phlex::Reactive.defer_binding_for(request)) do
78
+ Phlex::Reactive.instrument("defer", event) do
79
+ deferred_action(event)
80
+ end
68
81
  end
69
82
  end
70
83
  end
@@ -103,10 +103,22 @@ export function registerReactiveJs() {
103
103
  const list = parseOps(this.getAttribute("data-reactive-ops"))
104
104
  if (!list.length) return
105
105
  const targetId = this.getAttribute("target")
106
+ // Issue #237: the server stamps the verbose gate on the stream element
107
+ // itself (verbose_errors), so document-scoped ops are diagnosable too.
108
+ const verbose = this.getAttribute("data-reactive-verbose") === "true"
106
109
  // With a target: scope to that element (missing → no-op). Without: document.
107
110
  const root = targetId ? document.getElementById(targetId) : null
108
- if (targetId && !root) return
109
- applyOps(list, (args) => streamOpTargets(args, root))
111
+ if (targetId && !root) {
112
+ if (verbose && !zeroTargetAlreadyWarned(`missing-root|#${targetId}`)) {
113
+ console.warn(`[phlex-reactive] reactive:js stream target root #${targetId} is not in the DOM — its ops were dropped`)
114
+ }
115
+ return
116
+ }
117
+ applyOps(
118
+ list,
119
+ (args) => streamOpTargets(args, root),
120
+ verbose ? (name, args) => diagnoseStreamZeroTargets(name, args, root) : undefined,
121
+ )
110
122
  }
111
123
  }
112
124
 
@@ -1491,7 +1503,7 @@ function computeOpsList(raw) {
1491
1503
  // chain still applies — client-side default-deny, one bad op never takes down
1492
1504
  // its siblings. Object.hasOwn (not a bare read) so inherited Object members
1493
1505
  // ("constructor") can't masquerade as ops.
1494
- function applyOps(list, resolveTargets) {
1506
+ function applyOps(list, resolveTargets, onZeroTargets) {
1495
1507
  for (const entry of list) {
1496
1508
  if (!Array.isArray(entry)) continue
1497
1509
  const [name, args = {}] = entry
@@ -1499,8 +1511,68 @@ function applyOps(list, resolveTargets) {
1499
1511
  console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(name)} — skipped`)
1500
1512
  continue
1501
1513
  }
1502
- for (const el of resolveTargets(args)) CLIENT_OPS[name](el, args)
1514
+ const targets = resolveTargets(args)
1515
+ if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
1516
+ for (const el of targets) CLIENT_OPS[name](el, args)
1517
+ }
1518
+ }
1519
+
1520
+ // --- zero-target diagnostics (issue #237) -----------------------------------
1521
+ // An op resolving ZERO targets is indistinguishable from a working no-op, and
1522
+ // the documented scoping traps (nested-root ownership filter, root-self
1523
+ // selector, stream default scope) all present exactly that way. Under the
1524
+ // verbose gate (data-reactive-verbose, stamped when Phlex::Reactive
1525
+ // .verbose_errors is on — dev/test by default — or the debug attr) warn ONCE
1526
+ // per unique (label, selector, scope) with a targeted hint when the element
1527
+ // EXISTS but sits outside the op's scope. Everything below runs only after a
1528
+ // zero-match with the gate on; production (no attr) pays one boolean per empty
1529
+ // resolution and never probes the DOM.
1530
+ //
1531
+ // Dedupe is keyed per document (page lifetime): a WeakMap entry per document
1532
+ // means a fresh page — or a fresh unit-harness stub — starts clean, and the
1533
+ // per-keystroke reducer ($ops) path can never flood the console.
1534
+ const zeroTargetWarnSets = new WeakMap()
1535
+
1536
+ function zeroTargetAlreadyWarned(key) {
1537
+ const doc = globalThis.document
1538
+ if (!doc) return true
1539
+ let seen = zeroTargetWarnSets.get(doc)
1540
+ if (!seen) {
1541
+ seen = new Set()
1542
+ zeroTargetWarnSets.set(doc, seen)
1543
+ }
1544
+ if (seen.has(key)) return true
1545
+ seen.add(key)
1546
+ return false
1547
+ }
1548
+
1549
+ // Guarded probes: unit harnesses stub partial documents/roots, and an exotic
1550
+ // selector could throw — a diagnostic must never break the op pipeline.
1551
+ function countMatches(node, selector) {
1552
+ try {
1553
+ return node?.querySelectorAll?.(selector)?.length ?? 0
1554
+ } catch {
1555
+ return 0
1556
+ }
1557
+ }
1558
+
1559
+ function emitZeroTargetWarn(label, to, scope, hint) {
1560
+ if (zeroTargetAlreadyWarned(`${label}|${to}|${scope}`)) return
1561
+ console.warn(`[phlex-reactive] ${label} matched zero targets for selector "${to}" (${scope})${hint}`)
1562
+ }
1563
+
1564
+ // The stream-path diagnoser (reactive:js). No ownership filter exists here, so
1565
+ // the only trap is the target-root scope: the selector matches document-wide
1566
+ // but the op was scoped to the stream's target root.
1567
+ function diagnoseStreamZeroTargets(name, args, root) {
1568
+ const to = args.to
1569
+ if (typeof to !== "string" || to === "" || to === "@root") return
1570
+ let hint = ""
1571
+ if (root && !args.global) {
1572
+ const n = countMatches(globalThis.document, to)
1573
+ if (n > 0) hint = ` — it matches ${n} element(s) outside the stream's target root; use global: true`
1503
1574
  }
1575
+ emitZeroTargetWarn(`client op "${name}"`, to, root ? `scoped to #${root.id || "?"}` : "document-scoped", hint)
1504
1576
  }
1505
1577
 
1506
1578
  // Resolve a reactive:js op's targets against its `target` root (issue #97).
@@ -2204,7 +2276,11 @@ export default class extends Controller {
2204
2276
  const fire = signature !== null && signature !== this.#computeOpsSignature && eventDriven
2205
2277
  this.#computeOpsSignature = signature
2206
2278
  if (!fire) return
2207
- applyOps(list, (args) => this.#opTargets(args.to == null ? { ...args, to: "@root" } : args))
2279
+ applyOps(
2280
+ list,
2281
+ (args) => this.#opTargets(args.to == null ? { ...args, to: "@root" } : args),
2282
+ (name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
2283
+ )
2208
2284
  }
2209
2285
 
2210
2286
  // Client-side list navigation (combobox keyboard nav, issue #72). Wired by
@@ -3691,7 +3767,13 @@ export default class extends Controller {
3691
3767
  if (matches === null) return
3692
3768
  const fire = matches && !this.#onCompleteStates[i] && Boolean(event)
3693
3769
  this.#onCompleteStates[i] = matches
3694
- if (fire) applyOps(binding.ops, (args) => this.#opTargets(args.to == null ? { ...args, to: "@root" } : args))
3770
+ if (fire) {
3771
+ applyOps(
3772
+ binding.ops,
3773
+ (args) => this.#opTargets(args.to == null ? { ...args, to: "@root" } : args),
3774
+ (name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
3775
+ )
3776
+ }
3695
3777
  })
3696
3778
  }
3697
3779
 
@@ -4233,19 +4315,39 @@ export default class extends Controller {
4233
4315
  fd.append("token", token)
4234
4316
  fd.append("act", action)
4235
4317
  for (const [key, value] of Object.entries(params)) {
4236
- this.#appendField(fd, `params[${key}]`, value)
4318
+ this.#appendField(fd, this.#wireKey(key), value)
4237
4319
  }
4238
4320
  const multiNames = this.#multiFileNames(files)
4239
4321
  for (const { name, file, multiple } of files) {
4240
4322
  // params[name][] when the input is `multiple` (array shape even for one
4241
4323
  // file) OR the name repeats across inputs; otherwise a lone scalar file.
4242
4324
  const asArray = multiple || multiNames.has(name)
4243
- const key = asArray ? `params[${name}][]` : `params[${name}]`
4325
+ const key = asArray ? `${this.#wireKey(name)}[]` : this.#wireKey(name)
4244
4326
  fd.append(key, file, file.name)
4245
4327
  }
4246
4328
  return fd
4247
4329
  }
4248
4330
 
4331
+ // The multipart wire key for a collected field/file name: params[...] with
4332
+ // the name's OWN brackets expanded into nesting segments (issue #231). The
4333
+ // old verbatim wrap of a Rails-bracketed name — params[blog_post[summary]] —
4334
+ // is unparseable by Rack: a scalar arrived as {"blog_post[summary" => {"]" =>
4335
+ // value}} (data corruption written through `update!` with a 200) and a file
4336
+ // never reached its schema key (silent drop). Expanding blog_post[summary]
4337
+ // into params[blog_post][summary] mirrors the server's bracket_path exactly —
4338
+ // split at the first "[", then each non-empty bracket segment; an empty
4339
+ // trailing segment (tags[]) drops, matching how the JSON path's
4340
+ // expand_bracket_keys coerces the same name — so a multipart body and a JSON
4341
+ // body coerce identically for the same fields (the documented contract).
4342
+ // A flat name stays a single params[name] wrap, byte-identical to before.
4343
+ #wireKey(name) {
4344
+ const raw = String(name)
4345
+ const head = raw.indexOf("[")
4346
+ if (head === -1) return `params[${raw}]`
4347
+ const segments = [raw.slice(0, head), ...(raw.slice(head).match(/[^\[\]]+/g) ?? [])]
4348
+ return `params${segments.map((segment) => `[${segment}]`).join("")}`
4349
+ }
4350
+
4249
4351
  // Append a param leaf to FormData under its bracketed key. FormData carries
4250
4352
  // only strings, so a NON-scalar param (a nested object or an array) is
4251
4353
  // bracket-EXPANDED into params[key][sub] / params[key][index][...] fields —
@@ -4309,7 +4411,41 @@ export default class extends Controller {
4309
4411
  // logic lives in the shared applyOps so runOps and the reactive:js stream
4310
4412
  // action interpret the SAME vocabulary the SAME way (client-side default-deny).
4311
4413
  #applyOps(list) {
4312
- applyOps(list, (args) => this.#opTargets(args))
4414
+ applyOps(
4415
+ list,
4416
+ (args) => this.#opTargets(args),
4417
+ (name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
4418
+ )
4419
+ }
4420
+
4421
+ // Issue #237: the verbose gate for zero-target diagnostics — the
4422
+ // verbose_errors stamp (ON by default in dev/test) or full debug mode (a
4423
+ // debug user must never see less). Read live off the root like #debugEnabled.
4424
+ #verboseEnabled() {
4425
+ return this.element?.getAttribute?.("data-reactive-verbose") === "true" || this.#debugEnabled()
4426
+ }
4427
+
4428
+ // Issue #237: called when a selector-form target resolved to ZERO elements on
4429
+ // this root. Gated + deduped (module helpers); builds the trap-specific hint:
4430
+ // the root-self selector (root-scoped resolution never includes the root),
4431
+ // the nested-reactive-root ownership filter, or plain out-of-scope. All DOM
4432
+ // probes run only here — after a zero-match with the gate on.
4433
+ #diagnoseZeroTargets(label, args) {
4434
+ if (!this.#verboseEnabled()) return
4435
+ const to = args.to
4436
+ if (typeof to !== "string" || to === "" || to === "@root") return
4437
+ let hint = ""
4438
+ if (!args.global) {
4439
+ if (this.element?.matches?.(to)) {
4440
+ hint = " — the selector matches this component's own root, which root-scoped resolution never includes; use to: :root"
4441
+ } else if (countMatches(this.element, to) > 0) {
4442
+ hint = " — it matches only inside a nested reactive root (excluded by ownership scoping); use global: true"
4443
+ } else {
4444
+ const n = countMatches(globalThis.document, to)
4445
+ if (n > 0) hint = ` — it matches ${n} element(s) outside this scope; use global: true`
4446
+ }
4447
+ }
4448
+ emitZeroTargetWarn(label, to, `scoped to #${this.element?.id || "?"}`, hint)
4313
4449
  }
4314
4450
 
4315
4451
  // Resolve an op's targets: "@root" is this element; a selector resolves
@@ -4462,7 +4598,10 @@ export default class extends Controller {
4462
4598
  // this root's owned matches) or, with no `to:`, the trigger itself.
4463
4599
  #hintTargets(hint, trigger) {
4464
4600
  if (hint.to == null) return trigger ? [trigger] : []
4465
- return this.#opTargets({ to: hint.to })
4601
+ const targets = this.#opTargets({ to: hint.to })
4602
+ // Issue #237: a hint aimed at nothing is the same silent trap as an op.
4603
+ if (targets.length === 0) this.#diagnoseZeroTargets("busy/optimistic hint", { to: hint.to })
4604
+ return targets
4466
4605
  }
4467
4606
 
4468
4607
  // Swap the trigger's disabled/innerHTML for a pending hint, snapshotting the