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 +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +25 -1
- data/app/controllers/phlex/reactive/actions_controller.rb +19 -6
- data/app/javascript/phlex/reactive/reactive_controller.js +149 -10
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +1 -1
- data/lib/phlex/reactive/component/helpers.rb +4 -0
- data/lib/phlex/reactive/component.rb +8 -1
- data/lib/phlex/reactive/response.rb +5 -1
- data/lib/phlex/reactive/show_conditions.rb +1 -2
- data/lib/phlex/reactive/streamable.rb +13 -4
- data/lib/phlex/reactive/test_helpers/system.rb +1 -2
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +50 -0
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec14089cc43f0bf439305f48b9fca3ee8b0fe2ba83b44b341e22370ad11fc22d
|
|
4
|
+
data.tar.gz: 101ae52fce38a343185a2699082c79709dadf609d9401f199b9b9c5f181dda9c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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)
|
|
109
|
-
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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,
|
|
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 ?
|
|
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(
|
|
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
|
-
|
|
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
|