phlex-reactive 0.13.1 → 0.13.3

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.
@@ -1077,6 +1077,7 @@ const PERSIST_PREFIX = "phlex-reactive:persist:"
1077
1077
  // Never persisted regardless of author intent: no server default to restore
1078
1078
  // into (hidden, file), secrets (password), and non-value controls.
1079
1079
  const PERSIST_EXCLUDED_TYPES = new Set(["hidden", "file", "password", "submit", "button", "reset", "image"])
1080
+ const PERSIST_NO_VALUE = Symbol("persist-no-value")
1080
1081
  const PERSIST_STATE_ATTR = "data-reactive-persist-state"
1081
1082
  // The editor query #collectFields reads (minus the [name] guard — an editor's
1082
1083
  // name may live on its IDL `name` getter: Trix's `input=`-paired hidden input).
@@ -1235,6 +1236,14 @@ function persistEditorReady(el) {
1235
1236
  return typeof el.value === "string"
1236
1237
  }
1237
1238
 
1239
+ // The same question for #collectFields. A RICH editor (lexxy/trix) is only
1240
+ // ready once its custom element upgraded — Trix defines its elements in a
1241
+ // setTimeout after load — and reading it before that yields "", which is issue
1242
+ // #8. A bare [contenteditable] is plain DOM and always ready.
1243
+ function collectorEditorReady(el) {
1244
+ return PERSIST_EDITOR_TAGS.has(el.localName) ? persistEditorReady(el) : true
1245
+ }
1246
+
1238
1247
  // Ask the editor whether it is empty (Lexxy `isEmpty`; Trix
1239
1248
  // `editor.getDocument().isEmpty()`), else the exact-string fallback. An
1240
1249
  // attachment-only server body is therefore NON-blank and never overwritten.
@@ -1258,6 +1267,33 @@ function persistSelectMultiple(el) {
1258
1267
  return el.tagName === "SELECT" && el.multiple
1259
1268
  }
1260
1269
 
1270
+ // How many controls can contribute a value under each `[]` name, counted the
1271
+ // way persistSnapshot fills the slot — a radio is the exception there and here.
1272
+ // A group of ONE is unambiguous: its single entry can only have come from that
1273
+ // control.
1274
+ function persistGroupSizes(controls) {
1275
+ const sizes = new Map()
1276
+ for (const { el, name } of controls) {
1277
+ if (el.type === "radio" || !String(name).endsWith("[]")) continue
1278
+ sizes.set(name, (sizes.get(name) ?? 0) + 1)
1279
+ }
1280
+ return sizes
1281
+ }
1282
+
1283
+ // The value a control that cannot pick its own entry out of a list may take.
1284
+ // A list belongs to a `[]` group, and in a group of two or more nothing says
1285
+ // which entry came from which control — restoring it would paste
1286
+ // "freeform,news" into a text field. A group of ONE has no such ambiguity, and
1287
+ // refusing it would silently drop the draft of a plain field whose name merely
1288
+ // ends in `[]` (a list JS maintains), which worked before groups existed.
1289
+ // Returns PERSIST_NO_VALUE when the control must keep what the server rendered.
1290
+ function persistGenericValue(value, name, sizes) {
1291
+ if (!Array.isArray(value)) return value
1292
+ if (sizes.get(name) !== 1 || value.length !== 1) return PERSIST_NO_VALUE
1293
+
1294
+ return value[0]
1295
+ }
1296
+
1261
1297
  // Snapshot the owned controls: radio → the checked value (null when the group
1262
1298
  // has none, so a restore leaves it alone), checkbox → checked, multi-select →
1263
1299
  // the selected values, a rich editor → its serialized `value` (omitted while
@@ -1265,18 +1301,52 @@ function persistSelectMultiple(el) {
1265
1301
  // its textContent, else .value. Mirrors #collectFields' reads.
1266
1302
  function persistSnapshot(root, payload) {
1267
1303
  const fields = {}
1304
+ // A `[]` name is a group for every control that can contribute a value —
1305
+ // checkboxes, selects, text inputs, editors and contenteditables all append
1306
+ // to one array, mirroring #collectFields. The one exception is a RADIO group,
1307
+ // which means "pick one" and keeps its single value with or without the
1308
+ // suffix, exactly as the collector treats it.
1309
+ //
1310
+ // Reading the slot without this (fields[name] ?? []) breaks as soon as a
1311
+ // non-checkbox shares the group's name: a text input or an editor leaves a
1312
+ // string there, `.push` on it throws inside the draft write, and that write
1313
+ // is swallowed — the root then persists nothing at all, silently. Editors
1314
+ // are collected AFTER the native controls, so they always land last.
1315
+ const groupSlot = (name) => {
1316
+ const existing = fields[name]
1317
+ return Array.isArray(existing) ? existing : (fields[name] = [])
1318
+ }
1268
1319
  for (const { el, name, kind } of persistControls(root, payload)) {
1320
+ const group = String(name).endsWith("[]")
1269
1321
  if (kind === "editor") {
1270
- if (persistEditorReady(el)) fields[name] = el.value
1322
+ if (persistEditorReady(el)) {
1323
+ if (group) groupSlot(name).push(el.value)
1324
+ else fields[name] = el.value
1325
+ }
1271
1326
  } else if (kind === "contenteditable") {
1272
- fields[name] = el.textContent ?? ""
1327
+ const text = el.textContent ?? ""
1328
+ if (group) groupSlot(name).push(text)
1329
+ else fields[name] = text
1273
1330
  } else if (el.type === "radio") {
1274
1331
  if (el.checked) fields[name] = el.value
1275
1332
  else if (!Object.hasOwn(fields, name)) fields[name] = null
1276
1333
  } else if (el.type === "checkbox") {
1277
- fields[name] = el.checked
1334
+ // A `[]` group drafts the list of ticked values, mirroring #collectFields
1335
+ // (issue #258). Without this the boxes overwrote each other and the draft
1336
+ // held one boolean, which the restore then applied to every box of the
1337
+ // group. A lone checkbox keeps the boolean it has always been.
1338
+ if (group) {
1339
+ const slot = groupSlot(name)
1340
+ if (el.checked) slot.push(el.value)
1341
+ } else {
1342
+ fields[name] = el.checked
1343
+ }
1278
1344
  } else if (persistSelectMultiple(el)) {
1279
- fields[name] = [...el.options].filter((o) => o.selected).map((o) => o.value)
1345
+ const selected = [...el.options].filter((o) => o.selected).map((o) => o.value)
1346
+ if (group) groupSlot(name).push(...selected)
1347
+ else fields[name] = selected
1348
+ } else if (group) {
1349
+ groupSlot(name).push(el.value)
1280
1350
  } else {
1281
1351
  fields[name] = el.value
1282
1352
  }
@@ -1292,10 +1362,61 @@ function persistSnapshot(root, payload) {
1292
1362
  function persistApply(root, payload, fields) {
1293
1363
  const always = payload.restore === "always"
1294
1364
  const controls = persistControls(root, payload)
1365
+ // Which group names the SERVER rendered with a box already ticked. Computed
1366
+ // BEFORE the loop on purpose: the loop writes `checked`, so asking this
1367
+ // question from inside it would read THIS restore's own work — the first box
1368
+ // it ticks makes every later box of the same group look server-rendered, and
1369
+ // a draft of two values comes back as one. The radio branch below asks the
1370
+ // same question inline and stays correct only because a radio group holds a
1371
+ // single value.
1372
+ const groupSizes = persistGroupSizes(controls)
1373
+ const serverTicked = new Set()
1374
+ for (const control of controls) {
1375
+ if (control.kind === "native" && control.el.type === "checkbox" && control.el.checked) {
1376
+ serverTicked.add(control.name)
1377
+ }
1378
+ }
1295
1379
  for (const { el, name, kind } of controls) {
1296
1380
  if (!Object.hasOwn(fields, name)) continue
1297
- const value = fields[name]
1381
+ let value = fields[name]
1298
1382
  if (value === null || value === undefined) continue
1383
+ // A multi-select reads a list by matching option values, which is only
1384
+ // sound when the list is ITS list. In a group with another contributor the
1385
+ // entries are mixed, and a text value that happens to equal an option
1386
+ // would select it — measured, a draft of ["blue","freitext"] from a select
1387
+ // plus a text field selected both options. `?? 0` because a name without
1388
+ // the suffix is not in the map at all, and a plain `<select multiple
1389
+ // name="colors">` must keep restoring.
1390
+ if (Array.isArray(value) && persistSelectMultiple(el) && (groupSizes.get(name) ?? 0) > 1) continue
1391
+
1392
+ // An array belongs to a `[]` group, and only a control that can pick ITS
1393
+ // entry out of the list may read it: a checkbox matches by value, a
1394
+ // multi-select by option. Everything else — editors, contenteditables,
1395
+ // text inputs — keeps what the server rendered, because the list does not
1396
+ // record which entry came from which control. This sits ABOVE the branch
1397
+ // chain on purpose: below the editor branch it would never fire for the
1398
+ // very controls that land last in the snapshot.
1399
+ if (Array.isArray(value) && !(el.type === "checkbox" || persistSelectMultiple(el))) {
1400
+ value = persistGenericValue(value, name, groupSizes)
1401
+ if (value === PERSIST_NO_VALUE) continue
1402
+ }
1403
+ // The mirror, for a draft written BEFORE a group was drafted as a list
1404
+ // (#258): there `features[]` held ONE boolean, and applying it here ticks
1405
+ // every box of the group — precisely the state this fix removes, for as
1406
+ // long as the draft lives (default ttl 7 days). A group key that is not a
1407
+ // list is stale by definition, so the control keeps what the server
1408
+ // rendered and the next snapshot overwrites the key. It reads for a
1409
+ // multi-select too: 0.13.2 wrote last-writer-wins per name, so a checkbox
1410
+ // in a mixed group could leave its boolean under the select's name, and
1411
+ // under `restore: "always"` the select would then deselect everything —
1412
+ // `wanted` being Set{"true"} matches no option. Asking for the `[]` suffix
1413
+ // is what leaves a lone `gift` checkbox on the boolean it has always held —
1414
+ // and scoping the rule to the one key whose meaning changed is why
1415
+ // PERSIST_VERSION stays at 1: bumping it would also throw away the drafted
1416
+ // prose of every form that has no checkbox group at all.
1417
+ if ((el.type === "checkbox" || persistSelectMultiple(el)) && String(name).endsWith("[]") && !Array.isArray(value)) {
1418
+ continue
1419
+ }
1299
1420
  if (kind === "editor") {
1300
1421
  persistApplyEditor(root, el, name, value, always)
1301
1422
  } else if (kind === "contenteditable") {
@@ -1304,6 +1425,12 @@ function persistApply(root, payload, fields) {
1304
1425
  } else if (el.type === "radio") {
1305
1426
  if (!always && controls.some((c) => c.kind === "native" && c.el.type === "radio" && c.name === name && c.el.checked)) continue
1306
1427
  el.checked = el.value === String(value)
1428
+ } else if (el.type === "checkbox" && Array.isArray(value)) {
1429
+ // A drafted group ticks exactly the boxes it held. One box the SERVER
1430
+ // rendered ticked means it had a say, and the draft yields for the whole
1431
+ // group.
1432
+ if (!always && serverTicked.has(name)) continue
1433
+ el.checked = value.map(String).includes(el.value)
1307
1434
  } else if (el.type === "checkbox") {
1308
1435
  if (!always && el.checked) continue
1309
1436
  el.checked = Boolean(value)
@@ -1323,6 +1450,11 @@ function persistApply(root, payload, fields) {
1323
1450
  // editor's sanitizing import (Trix HTMLParser, Lexxy $generateNodesFromDOM +
1324
1451
  // sanitizer). A throw (Lexxy before its editor exists) never escapes connect.
1325
1452
  function persistApplyEditor(root, el, name, value, always) {
1453
+ // A list never reaches here: both callers resolve it through
1454
+ // persistGenericValue first — the group of two or more has no mapping back to
1455
+ // this editor, the group of one does. Belt and braces, because this is the
1456
+ // one apply path with a second entry point.
1457
+ if (Array.isArray(value)) return
1326
1458
  if (!persistEditorReady(el)) return // not upgraded yet — persistDeferEditors re-applies after define
1327
1459
  if (!always && !persistEditorBlank(el)) return
1328
1460
  try {
@@ -1348,10 +1480,12 @@ function persistDeferEditors(root, payload, fields) {
1348
1480
  for (const tag of pending) {
1349
1481
  registry.whenDefined(tag).then(() => {
1350
1482
  if (!root.isConnected) return
1351
- for (const { el, name, kind } of persistControls(root, payload)) {
1483
+ const deferred = persistControls(root, payload)
1484
+ const sizes = persistGroupSizes(deferred)
1485
+ for (const { el, name, kind } of deferred) {
1352
1486
  if (kind !== "editor" || el.localName !== tag || !Object.hasOwn(fields, name)) continue
1353
- const value = fields[name]
1354
- if (value === null || value === undefined) continue
1487
+ const value = persistGenericValue(fields[name], name, sizes)
1488
+ if (value === null || value === undefined || value === PERSIST_NO_VALUE) continue
1355
1489
  persistApplyEditor(root, el, name, value, always)
1356
1490
  }
1357
1491
  })
@@ -1813,6 +1947,94 @@ function parseOps(raw) {
1813
1947
  }
1814
1948
  }
1815
1949
 
1950
+ // The TEXT reading of a compute control (issue #262) — what a :string input
1951
+ // hands the reducer and what the identity/cross-root mirrors paint. A checkbox
1952
+ // reads its CHECKED STATE ("true"/"false", the strings reactive_show compares
1953
+ // against): its .value is a constant — "1", "on", whatever the markup says —
1954
+ // so reading it told the reducer nothing. A radio reads its value only while
1955
+ // checked ("" otherwise; the resolver hands over the checked radio of a group).
1956
+ // Anything else reads .value, as it always has.
1957
+ //
1958
+ // Every compute helper takes a CONTROL — the { el, kind } record #recompute's
1959
+ // resolver builds, kind being "checkbox", "radio" or "" — and never re-reads
1960
+ // el.type: the resolver reads it ONCE per name. Re-reading it in each helper
1961
+ // cost the 30-input calculator bench ~60% (16.8 → 27 µs/iter, measured).
1962
+ function computeText({ el, kind }) {
1963
+ if (!el) return ""
1964
+ if (kind === "checkbox") return el.checked ? "true" : "false"
1965
+ if (kind === "radio") return el.checked ? (el.value ?? "") : ""
1966
+ return el.value ?? ""
1967
+ }
1968
+
1969
+ // The control for a name nothing owned resolves to.
1970
+ const COMPUTE_NO_CONTROL = Object.freeze({ el: null, kind: "" })
1971
+
1972
+ // Whether a value counts as "on" — for a :boolean input read off a control that
1973
+ // is not a checkbox, and for an output written INTO a checkbox. A boolean is
1974
+ // itself; otherwise "", "0" and "false" are off (what a hidden flag field or a
1975
+ // reducer returning 0 means) and anything else is on.
1976
+ function computeTruthy(value) {
1977
+ if (typeof value === "boolean") return value
1978
+ if (value == null) return false
1979
+ const text = String(value)
1980
+ return text !== "" && text !== "0" && text !== "false"
1981
+ }
1982
+
1983
+ // One declared input's value for the reducer, coerced by its declared type
1984
+ // (issue #104; checked-state controls issue #262):
1985
+ //
1986
+ // "string" → the text reading, raw (blank/absent → "")
1987
+ // "boolean" → a checkbox's checked state; any other control by computeTruthy
1988
+ // "number" → a checkbox is 1/0; anything else through Number (blank/NaN → 0,
1989
+ // the nanToZero the hand-written calculators use)
1990
+ //
1991
+ // A checkbox is 1/0 and never Number(its value): a box is a yes/no, and a
1992
+ // reducer that wants an amount writes `gift ? 25 : 0`.
1993
+ function computeValue(control, type) {
1994
+ if (type === "string") return computeText(control)
1995
+ const box = control.kind === "checkbox"
1996
+ if (type === "boolean") return box ? Boolean(control.el.checked) : computeTruthy(computeText(control))
1997
+ if (box) return control.el.checked ? 1 : 0
1998
+ const n = Number(computeText(control))
1999
+ return Number.isFinite(n) ? n : 0
2000
+ }
2001
+
2002
+ // Write one reducer output into the control its name resolved to (issue #262),
2003
+ // change-guarded. Returns the element to announce with an `input` event, or
2004
+ // null when nothing changed. A checkbox takes the result as its checked state
2005
+ // and a radio group checks the radio carrying it — neither ever has its value
2006
+ // attribute rewritten, which would change what the control SUBMITS. Anything
2007
+ // else takes the result as its .value (issue #76).
2008
+ function computeWrite(root, owns, { el, kind }, domName, value) {
2009
+ if (kind === "checkbox") {
2010
+ const checked = computeTruthy(value)
2011
+ if (Boolean(el.checked) === checked) return null
2012
+ el.checked = checked
2013
+ return el
2014
+ }
2015
+ if (kind === "radio") return computeCheckRadio(root, owns, domName, String(value))
2016
+ if (String(value) === el.value) return null
2017
+ el.value = value
2018
+ return el
2019
+ }
2020
+
2021
+ // Check the owned radio of a group whose value is `wanted`, unchecking the
2022
+ // rest — the same per-radio rule a restored draft applies. A value no radio
2023
+ // carries clears the group. Returns the radio that GAINED the check, or, for a
2024
+ // cleared group, the one that lost it; null when the selection already matched.
2025
+ function computeCheckRadio(root, owns, domName, wanted) {
2026
+ let announced = null
2027
+ for (const el of root.querySelectorAll(`[name="${domName}"]`)) {
2028
+ if (el.type !== "radio" || !owns(el)) continue
2029
+ const checked = el.value === wanted
2030
+ if (Boolean(el.checked) === checked) continue
2031
+ el.checked = checked
2032
+ if (checked) announced = el
2033
+ else announced ??= el
2034
+ }
2035
+ return announced
2036
+ }
2037
+
1816
2038
  // Normalize a reducer's reserved $ops output (issue #226): the compute `ops`
1817
2039
  // builder (its .ops list), a raw [[name, args], ...] array, or null/undefined
1818
2040
  // (no effect this pass). An EMPTY list is the same as null — "nothing to run"
@@ -2480,8 +2702,14 @@ export default class extends Controller {
2480
2702
  // predicate in the common no-nested-root case (skipping closest() entirely)
2481
2703
  // and the exact #ownsField check when a nested reactive root is present
2482
2704
  // (issue #15 scoping, byte-identical to before). Resolution is memoized in a
2483
- // per-CALL Map, FIRST-WINS, so a name read as an input AND written as an
2484
- // output resolves to the SAME element and is queried once.
2705
+ // per-CALL Map, so a name read as an input AND written as an output
2706
+ // resolves to the SAME element and is queried once.
2707
+ //
2708
+ // Which element a name resolves to (issue #262, mirroring #showFieldValue): a
2709
+ // CHECKBOX wins over the hidden companion Rails renders before it; a radio
2710
+ // group resolves to its CHECKED radio (any radio of the group when none is);
2711
+ // anything else is first-wins. Resolving first-wins across the board handed
2712
+ // the reducer the companion's constant "0" and the first radio's value.
2485
2713
  //
2486
2714
  // Why per-name `[name="X"]` queries and not one bare `[name]` sweep: a single
2487
2715
  // sweep is the natural "one walk", but the resolver must issue the SAME
@@ -2500,25 +2728,37 @@ export default class extends Controller {
2500
2728
 
2501
2729
  const owns = this.#ownershipFilter()
2502
2730
  const byName = new Map()
2503
- const ownedField = (name) => {
2504
- if (byName.has(name)) return byName.get(name)
2505
- let found = null
2731
+ const ownedControl = (name) => {
2732
+ const known = byName.get(name)
2733
+ if (known) return known
2734
+ let radio = null
2735
+ let first = null
2736
+ let control = null
2506
2737
  for (const el of this.element.querySelectorAll(`[name="${scoped(name)}"]`)) {
2507
- if (owns(el)) {
2508
- found = el // FIRST-WINS (radio groups, Rails hidden+checkbox name pairs)
2738
+ if (!owns(el)) continue
2739
+ const kind = el.type // read ONCE per element — see computeText
2740
+ if (kind === "checkbox" || (kind === "radio" && el.checked)) {
2741
+ control = { el, kind }
2509
2742
  break
2510
2743
  }
2744
+ if (kind === "radio") radio ??= el
2745
+ else first ??= el
2511
2746
  }
2512
- byName.set(name, found)
2513
- return found
2747
+ if (!control) {
2748
+ if (radio) control = { el: radio, kind: "radio" }
2749
+ else control = first ? { el: first, kind: "" } : COMPUTE_NO_CONTROL
2750
+ }
2751
+ byName.set(name, control)
2752
+ return control
2514
2753
  }
2515
2754
 
2516
2755
  // Identity-mirror pass (issue #104), ALWAYS run — even with NO registered
2517
2756
  // reducer, so reactive_text(:title) mirrors a field into its text node with
2518
- // zero reducer wiring. Each declared input's RAW string value is written to
2519
- // its owned [data-reactive-text="<name>"] node(s). It runs BEFORE the reducer
2757
+ // zero reducer wiring. Each declared input's RAW text reading (computeText —
2758
+ // a checkbox paints "true"/"false", issue #262) is written to its owned
2759
+ // [data-reactive-text="<name>"] node(s). It runs BEFORE the reducer
2520
2760
  // early-return below so a reducer-less binding still mirrors.
2521
- for (const name of inputs) this.#mirrorText(name, ownedField(name)?.value ?? "")
2761
+ for (const name of inputs) this.#mirrorText(name, computeText(ownedControl(name)))
2522
2762
 
2523
2763
  const key = this.element.getAttribute("data-reactive-compute-reducer-param")
2524
2764
  const reduce = key ? computeReducer(key) : null
@@ -2526,26 +2766,18 @@ export default class extends Controller {
2526
2766
  // No reducer registered: the identity pass above still ran, so declared
2527
2767
  // cross-root mirrors of the INPUT names still paint (issue #159) — a
2528
2768
  // reducer-less binding mirrors, exactly like the owned-text-node case.
2529
- this.#applyComputeMirrors({}, ownedField)
2769
+ this.#applyComputeMirrors({}, ownedControl)
2530
2770
  return
2531
2771
  }
2532
2772
 
2533
2773
  const outputs = this.#parseComputeList("data-reactive-compute-outputs-param")
2534
2774
 
2535
- // Coerce each input per its declared type (issue #104): "string" → the raw
2536
- // display string (blank/absent → ""); else ("number", the array-form default)
2537
- // → the numeric coercion (blank/NaN → 0, the nanToZero the hand-written
2538
- // calculators use). Reads from the memoized resolver — no re-query.
2775
+ // Coerce each input per its declared type (computeValue): "string" raw,
2776
+ // "boolean" a real boolean, else ("number", the array-form default) the
2777
+ // numeric coercion. A checkbox contributes its CHECKED STATE under every
2778
+ // type (issue #262). Reads from the memoized resolver — no re-query.
2539
2779
  const values = {}
2540
- for (const [name, type] of inputPairs) {
2541
- const field = ownedField(name)
2542
- if (type === "string") {
2543
- values[name] = field?.value ?? ""
2544
- } else {
2545
- const n = Number(field?.value)
2546
- values[name] = Number.isFinite(n) ? n : 0
2547
- }
2548
- }
2780
+ for (const [name, type] of inputPairs) values[name] = computeValue(ownedControl(name), type)
2549
2781
 
2550
2782
  // meta.changed stays on #changedComputeField (its own #ownsField check over
2551
2783
  // the raw event target) — NOT this resolver. The issue-#15 nested-rejection
@@ -2564,7 +2796,8 @@ export default class extends Controller {
2564
2796
  // 1. BATCH the field writes from the ONE result. Each output name in the
2565
2797
  // allowlist (outputs:) whose owned field's value actually changes is
2566
2798
  // written now (change-guarded) and remembered — but NO `input` event is
2567
- // dispatched yet, so nothing re-enters mid-batch.
2799
+ // dispatched yet, so nothing re-enters mid-batch. A checkbox or radio
2800
+ // output is written as its CHECKED state (computeWrite, issue #262).
2568
2801
  // 2. PAINT the sinks from the SETTLED values: any owned reactive_text node by
2569
2802
  // presence (issue #183 change #4 — a text node no longer needs its name in
2570
2803
  // outputs:), then the cross-root mirror: ids (issue #159).
@@ -2578,11 +2811,10 @@ export default class extends Controller {
2578
2811
  const changedFields = []
2579
2812
  for (const name of outputs) {
2580
2813
  if (name === "$ops" || !(name in result)) continue
2581
- const field = ownedField(name)
2582
- if (!field) continue // a non-field output paints as a text sink in phase 2
2583
- if (String(result[name]) === field.value) continue // change-guard — unchanged, skip
2584
- field.value = result[name]
2585
- changedFields.push(field)
2814
+ const control = ownedControl(name)
2815
+ if (!control.el) continue // a non-field output paints as a text sink in phase 2
2816
+ const written = computeWrite(this.element, owns, control, scoped(name), result[name])
2817
+ if (written) changedFields.push(written) // null = change-guard, unchanged
2586
2818
  }
2587
2819
 
2588
2820
  // Phase 2 — text sinks declare themselves (issue #183 change #4): every result
@@ -2599,7 +2831,7 @@ export default class extends Controller {
2599
2831
 
2600
2832
  // Cross-root text mirrors (issue #159) — AFTER the batch + text sinks, so a
2601
2833
  // mirror keyed on a just-written output paints the settled value.
2602
- this.#applyComputeMirrors(result, ownedField)
2834
+ this.#applyComputeMirrors(result, ownedControl)
2603
2835
 
2604
2836
  // Phase 3 — dispatch the deferred `input` events (issue #183). Real browsers
2605
2837
  // do NOT fire `input` on a programmatic .value write (issue #76), so we do it
@@ -3167,10 +3399,11 @@ export default class extends Controller {
3167
3399
  // only (never innerHTML), change-guarded, and NO input dispatch — same
3168
3400
  // contract as #mirrorText. With no mirror declared this is one getAttribute
3169
3401
  // and out — the shipped compute path never touches the document.
3170
- #applyComputeMirrors(result, ownedField) {
3402
+ #applyComputeMirrors(result, ownedControl) {
3171
3403
  const mirror = this.#parseComputeMirror()
3172
3404
  for (const [name, selectors] of Object.entries(mirror)) {
3173
- const value = name in result ? result[name] : ownedField(name)?.value
3405
+ const control = name in result ? null : ownedControl(name)
3406
+ const value = name in result ? result[name] : control.el ? computeText(control) : undefined
3174
3407
  if (value === undefined || value === null) continue
3175
3408
  const text = String(value)
3176
3409
  for (const sel of Array.isArray(selectors) ? selectors : [selectors]) {
@@ -3894,6 +4127,7 @@ export default class extends Controller {
3894
4127
  const fields = {}
3895
4128
  const files = []
3896
4129
  const owns = this.#ownershipFilter() // compute ONCE per dispatch (issue #117)
4130
+ const controls = []
3897
4131
  this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
3898
4132
  if (!owns(field)) return
3899
4133
  if (field.type === "file") {
@@ -3901,6 +4135,39 @@ export default class extends Controller {
3901
4135
  // shape (params[name][]) even when the user picked exactly one file —
3902
4136
  // otherwise a [:file] schema would see a lone scalar upload and drop it.
3903
4137
  for (const file of field.files ?? []) files.push({ name: field.name, file, multiple: field.multiple })
4138
+ } else {
4139
+ // Held for a second pass: a hidden input is only a companion if a
4140
+ // checkbox somewhere in the root shares its name, which the first
4141
+ // occurrence cannot know yet.
4142
+ controls.push(field)
4143
+ }
4144
+ })
4145
+ // Collected before the first pass so the editor DOM is walked once.
4146
+ const editors = []
4147
+ this.element.querySelectorAll(`[name]${PERSIST_EDITOR_SELECTOR}`).forEach((el) => {
4148
+ if (owns(el)) editors.push(el) // the SAME hoisted predicate (nested reactive root, issue #15)
4149
+ })
4150
+ const arrayNames = this.#arrayFieldNames(controls)
4151
+ const companionNames = this.#companionNames(controls)
4152
+ for (const field of controls) {
4153
+ if (arrayNames.has(field.name)) {
4154
+ const slot = fields[field.name] ?? (fields[field.name] = [])
4155
+ if (field.type === "checkbox" || field.type === "radio") {
4156
+ // An unchecked box contributes NOTHING, the way a native submission
4157
+ // leaves it out. The group's value is the list of checked values, and
4158
+ // with none checked that list stays an EMPTY ARRAY rather than
4159
+ // vanishing: the action can tell "the operator cleared them" from
4160
+ // "the group never rendered", and an [:string] schema coerces [] to
4161
+ // []. A form body cannot carry the empty array, so #buildFormData
4162
+ // announces the group there instead.
4163
+ if (field.checked) slot.push(field.value)
4164
+ } else if (field.type === "hidden") {
4165
+ if (!companionNames.has(field.name)) slot.push(field.value)
4166
+ } else if (field.multiple && field.options) {
4167
+ for (const option of field.options) if (option.selected) slot.push(option.value)
4168
+ } else {
4169
+ slot.push(field.value)
4170
+ }
3904
4171
  } else if (field.type === "checkbox") {
3905
4172
  fields[field.name] = field.checked
3906
4173
  } else if (field.type === "radio") {
@@ -3908,30 +4175,103 @@ export default class extends Controller {
3908
4175
  } else {
3909
4176
  fields[field.name] = field.value
3910
4177
  }
3911
- })
4178
+ }
3912
4179
  // Named rich-text / custom editors (lexxy-editor, trix-editor) and bare
3913
4180
  // [contenteditable]. These aren't input/select/textarea, so the query above
3914
4181
  // skips them — without this, a reactive save posts an empty value and
3915
4182
  // silently wipes the field (issue #8). Read whatever the element exposes:
3916
4183
  // a custom editor's serialized `.value`, else its contenteditable text.
3917
- // Only fill a name the standard controls left absent or empty, so a synced
3918
- // hidden input (e.g. Trix mirrors into one) still wins when populated.
3919
- this.element
3920
- .querySelectorAll("[name]:is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])")
4184
+ // Under a plain name: only fill what the standard controls left absent or
4185
+ // empty, so a synced hidden input (e.g. Trix mirrors into one) still wins
4186
+ // when populated. Under a `[]` name the editor APPENDS to the group slot
4187
+ // instead, and a same-named hidden keeps its own say: nothing here can
4188
+ // tell a hidden that mirrors this editor from one that is a list JS
4189
+ // maintains.
4190
+ editors
3921
4191
  .forEach((el) => {
3922
- if (!owns(el)) return // reuse the SAME hoisted predicate (nested reactive root — issue #15)
3923
4192
  // A plain element (e.g. a <div contenteditable>) has no `name` IDL
3924
4193
  // property — only the attribute — so read getAttribute, not el.name.
3925
4194
  const name = el.getAttribute("name")
3926
4195
  if (!name) return
4196
+ const own = el.value ?? el.textContent ?? el.innerHTML ?? ""
4197
+ // A `[]` name is a group here too: the editor APPENDS its value
4198
+ // instead of replacing the slot. Assigning a scalar posted
4199
+ // `{"notes[]": "<p>x</p>"}` while the draft snapshot pushed the same
4200
+ // control into an array — the wire and the draft disagreeing about one
4201
+ // field, and a declared array type seeing a string. A same-named hidden
4202
+ // is NOT read as this editor's twin: nothing here can tell a mirror
4203
+ // from a list JS maintains, and a value posted twice is visible while a
4204
+ // suppressed one is not.
3927
4205
  const existing = fields[name]
4206
+ // An editor that has not upgraded yet contributes NOTHING to a group.
4207
+ // Trix defines its elements in a setTimeout after load, and its "" is
4208
+ // not an empty value but an absent one: persistSnapshot omits such an
4209
+ // editor for the same reason, so this keeps the wire and the draft
4210
+ // saying the same thing.
4211
+ if (String(name).endsWith("[]") && !collectorEditorReady(el)) return
4212
+ if (String(name).endsWith("[]") && (existing === undefined || Array.isArray(existing))) {
4213
+ const slot = Array.isArray(existing) ? existing : (fields[name] = [])
4214
+ slot.push(own)
4215
+ return
4216
+ }
4217
+ // A scalar already under a `[]` name can only be a RADIO's: a radio
4218
+ // means "pick one" and keeps its single value with or without the
4219
+ // suffix, which is why #arrayFieldNames excepts it. Converting that to
4220
+ // a group here would discard the chosen value — measured, the post lost
4221
+ // it — so the editor stands down and the rule below applies, which
4222
+ // never overwrites a populated name.
4223
+ // Only fill what the standard controls left absent or empty, so a
4224
+ // synced hidden input still wins when populated.
3928
4225
  if (existing == null || existing === "") {
3929
- fields[name] = el.value ?? el.textContent ?? el.innerHTML ?? ""
4226
+ fields[name] = own
3930
4227
  }
3931
4228
  })
3932
4229
  return { fields, files }
3933
4230
  }
3934
4231
 
4232
+ // Names collected as an ARRAY rather than a single value: a name carrying the
4233
+ // `[]` suffix, the HTML convention for a group. That suffix is the ONLY
4234
+ // trigger — a group says so, it is not inferred from two controls happening
4235
+ // to share a name. Radios are excluded BY DESIGN: a radio group shares one
4236
+ // name to mean "pick one", and it keeps posting the single checked value,
4237
+ // suffix or not.
4238
+ //
4239
+ // Issue #258: without this, `fields[name] = field.checked` wrote a boolean per
4240
+ // checkbox and same-named boxes overwrote each other, so three boxes with two
4241
+ // ticked left the browser as the LAST box's checked state — the chosen values
4242
+ // never reached the wire, whatever the action's schema declared.
4243
+ #arrayFieldNames(controls) {
4244
+ const names = new Set()
4245
+ for (const field of controls) {
4246
+ // A radio group shares one name BY DESIGN to mean "pick one" and keeps
4247
+ // its single checked value, `[]` suffix or not.
4248
+ if (field.type === "radio") continue
4249
+ if (String(field.name).endsWith("[]")) names.add(field.name)
4250
+ }
4251
+ return names
4252
+ }
4253
+
4254
+ // Names whose group carries a hidden COMPANION: a hidden input is Rails' way
4255
+ // of giving a checkbox a value for the unchecked case, and it is never a
4256
+ // chosen value. Measured from the helpers, the three shapes are:
4257
+ //
4258
+ // check_box(:u, :sub)
4259
+ // <input name="u[sub]" type="hidden" value="0"><input type="checkbox" value="1" name="u[sub]">
4260
+ // check_box(:u, :ids, {multiple: true}, "3")
4261
+ // <input name="u[ids][]" type="hidden" value="0"><input type="checkbox" value="3" name="u[ids][]">
4262
+ // collection_check_boxes(...) / an unchecked_value of nil
4263
+ // <input type="hidden" name="u[ids][]" value=""> — or no hidden at all
4264
+ //
4265
+ // The value differs (unchecked_value, blank, absent), so the value cannot be
4266
+ // the test. What identifies a companion is that a checkbox shares its name.
4267
+ // A hidden WITHOUT a same-named checkbox is a list JS maintains, and its
4268
+ // value is a chosen value like any other.
4269
+ #companionNames(controls) {
4270
+ const names = new Set()
4271
+ for (const field of controls) if (field.type === "checkbox") names.add(field.name)
4272
+ return names
4273
+ }
4274
+
3935
4275
  // Re-compute the dirty flag for EVERY field this root owns in one pass (issue
3936
4276
  // #103), then reflect the total onto the root. Called on an owned field's input
3937
4277
  // (trackDirty), on connect (baseline seed), and after a turbo:morph-element
@@ -4759,9 +5099,35 @@ export default class extends Controller {
4759
5099
  const fd = new FormData()
4760
5100
  fd.append("token", token)
4761
5101
  fd.append("act", action)
5102
+ const emptyGroups = []
4762
5103
  for (const [key, value] of Object.entries(params)) {
4763
- this.#appendField(fd, this.#wireKey(key), value)
5104
+ // A `[]` name carrying an array is the group shape (issue #258): every
5105
+ // element goes to params[name][], which Rack parses as an array. The
5106
+ // indexed form #appendField writes for a plain array (params[name][0],
5107
+ // params[name][1]) arrives as a hash of index keys — ParamSchema's array
5108
+ // type normalizes that back, but only an array type does, so the two
5109
+ // bodies would stop coercing identically for the same fields.
5110
+ //
5111
+ // An EMPTY group cannot be an empty array in a form body, so it is
5112
+ // ANNOUNCED instead: its key stays absent from params and its name goes
5113
+ // into `empty_groups[]`, a field of its own beside token/act/params. A
5114
+ // blank entry was the obvious alternative and is ambiguous — Rails leaves
5115
+ // `[""]` to the caller, and a `[:date]` or `[:file]` element reads it as
5116
+ // "did not come in", so treating it as "cleared" would change what those
5117
+ // params mean. The field is additive: a server that ignores it behaves
5118
+ // exactly as it does today, and so does a client that never sends it.
5119
+ if (Array.isArray(value) && String(key).endsWith("[]")) {
5120
+ if (value.length === 0) {
5121
+ emptyGroups.push(String(key).slice(0, -2))
5122
+ } else {
5123
+ const wire = `${this.#wireKey(key)}[]`
5124
+ for (const element of value) fd.append(wire, String(element))
5125
+ }
5126
+ } else {
5127
+ this.#appendField(fd, this.#wireKey(key), value)
5128
+ }
4764
5129
  }
5130
+ for (const name of emptyGroups) fd.append("empty_groups[]", name)
4765
5131
  const multiNames = this.#multiFileNames(files)
4766
5132
  for (const { name, file, multiple } of files) {
4767
5133
  // params[name][] when the input is `multiple` (array shape even for one