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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '049bef601e70264a857668de2ad7766f9b4edae020ded0844d2f5efacdc0607d'
4
- data.tar.gz: 1293bfd5f14ef03fa1889444c662846d4f1129155e1b9ea458b48529a7644b3b
3
+ metadata.gz: 6f070adcfcb6e3061829d42551e50803c209bd895c10a612d981b0359d40f59a
4
+ data.tar.gz: 3ff24e3f7c13b1aee4cf66f9187cced2ecad0cf98a69ec1a536d9eb452a86b3b
5
5
  SHA512:
6
- metadata.gz: d3fe07e736d776bdcdc594ef9d4cb3e91aea4c0cf81d6bf9c0f703d5de744e2d60394b79c4bad02c32b98ac04fc99d3b1a6746c88ff8e377e94e259765ddbfec
7
- data.tar.gz: eb3c04c9cea640d1c6662e602a77b1b49e4e5e7204cb6c587916440c5695064a84219348127b7282ee83b9dd6e6fa57dced2ca9dd79fd7eba1cb1e33e56d6419
6
+ metadata.gz: 7166c081bba35822dd588ead985237cbee23f11dcb6ef62ffaecfb483626eb007cc8613824896c0916253bdaea034c1b0794b64adf9f73c6e30fa462f5075a15
7
+ data.tar.gz: f9a8c4b4b09eec913bd4f9d0085620503e43e7d7beb5a2cb190dfb1e7fd3f23c702c6ee768fd2bb8304a76ffbd038b4bde4827e92c04f0f1a754c7fcf611c421
data/CHANGELOG.md CHANGED
@@ -441,6 +441,159 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
441
441
 
442
442
  ### Fixed
443
443
 
444
+ - **`reactive_compute` never saw a checkbox's checked state (#262).**
445
+ `#recompute` resolved each declared name first-wins and read `.value`, which
446
+ for a checkbox is a constant. Measured: a Rails `check_box` pair read `0`
447
+ ticked or not (the hidden companion comes first), a lone `value="1"` box read
448
+ `1` either way, and a box with no value attribute read `0` (`Number("on")` is
449
+ NaN). A radio group read its FIRST radio's value, whichever was checked. The
450
+ reducer ran on the toggle, from values that could not have changed.
451
+
452
+ A checkbox now contributes its checked state, coerced by the declared type:
453
+ `1`/`0` as a `:number` (and in the array form), `"true"`/`"false"` as a
454
+ `:string`, and `true`/`false` under the new **`:boolean`** type. It wins over
455
+ its same-named hidden companion, as it already did for `reactive_show`. A radio
456
+ group contributes its checked radio's value. The identity mirror and the
457
+ `mirror:` fallback paint the same reading.
458
+
459
+ Outputs were wrong the same way: a result whose name resolved to a checkbox
460
+ pair wrote `.value` on the hidden companion, changing what the UNCHECKED state
461
+ submits and leaving the box alone. An output now sets a checkbox's `checked`
462
+ from the result's truthiness (`""`, `"0"`, `"false"`, `0` and `false` untick)
463
+ and checks the radio of a group that carries the result.
464
+
465
+ **If a reducer relied on the old reading**, it changes: a lone checkbox with a
466
+ numeric `value` used to read that number regardless of state and now reads
467
+ `1`/`0` — write the amount in the reducer (`gift ? 25 : 0`). Apps that worked
468
+ around the defect by reading the box from `document` inside the reducer keep
469
+ working and can drop the workaround.
470
+
471
+ Cost, same machine, happy-dom (engine-relative): the 30-input calculator
472
+ bench goes from 16.3 to 18.4 µs/iter, allocations flat. That is one `el.type`
473
+ read per declared name, which is what telling a checkbox from a text field
474
+ takes.
475
+
476
+ - **The dropped-param hints said nothing for a schema with string keys.**
477
+ `ParamSchema.compile` keeps keys as the author wrote them, so
478
+ `params: { "date" => :string }` is valid, but the #16/#21 hints only looked
479
+ up symbol keys. The param was still dropped and logged, just without the hint
480
+ that says where the schema declares it.
481
+
482
+ - **A checkbox group collapsed to one boolean, and the chosen values never left
483
+ the browser (#258).** `#collectFields` wrote `fields[name] = field.checked` for
484
+ every checkbox, so several boxes sharing a `features[]` name overwrote each
485
+ other and the action received the LAST box's checked state — `{}` under a
486
+ `[:string]` schema, `"false"` under a flat `:string` one, silent either way,
487
+ while a native submission of the same boxes sends
488
+ `features[]=news&features[]=events`. A name ending in `[]` is now collected as
489
+ an array of the chosen values: a ticked box contributes its `value`, an
490
+ unticked one nothing, a `<select multiple>` its selected options. The suffix is
491
+ the only trigger — a group says so rather than being inferred from two controls
492
+ sharing a name.
493
+
494
+ **That makes the suffix a migration point.** ANY `[]`-named control now
495
+ contributes to an array, including a single one and including a named rich
496
+ editor or bare contenteditable, which the collector reads in a second pass: a
497
+ lone `<input type="text" name="tags[]">` used to post `"abc"` and now posts
498
+ `["abc"]`. An editor sharing its `[]` name with another control adds its
499
+ value to the group instead of standing down behind it, so the entry count
500
+ changes there too. Beside a radio it still stands down, because a radio keeps
501
+ its single value with or without the suffix. A ready but empty editor
502
+ contributes an empty string, the way an empty text field in the same group
503
+ does. An unticked box still contributes nothing. A hidden input under the
504
+ same name keeps contributing: nothing in the DOM tells a hidden that mirrors
505
+ an editor from one that is a list JS maintains, and a value posted twice is
506
+ visible where a swallowed one is not. Against a flat `params: { tags: :string
507
+ }` the array coerces to the literal `"[\"abc\"]"` — silently, with a 200. A
508
+ control whose name ends in `[]` has to be declared as an array type (`tags:
509
+ [:string]`), or renamed without the suffix if it was never meant as a list.
510
+
511
+ Three shapes keep their meaning on purpose: a lone checkbox without `[]` stays
512
+ the documented yes/no boolean, a radio group keeps its single checked value
513
+ with or without the suffix, and a hidden input sharing a name with a checkbox
514
+ is that box's companion. The companion is identified by the shared name, not
515
+ by its value, because Rails renders three of them: `check_box` emits
516
+ `value="0"`, `check_box(..., multiple: true)` the same under a `[]` name, and
517
+ `collection_check_boxes` a blank one — or none at all with
518
+ `unchecked_value: nil`. Reading the second shape by value collected
519
+ `["0","0","0","3"]` for three boxes with the third ticked.
520
+
521
+ A form body can't carry an empty array, so a cleared group used to go missing
522
+ whenever the client sent a form body (as soon as a file input holds a file),
523
+ and the action got its keyword default instead of `[]`. Now the client names
524
+ the cleared group in `empty_groups[]`, a field next to `token`, `act` and
525
+ `params`, and the endpoint sets it to `[]`. A request without that field
526
+ behaves as before, and a group that carries values keeps them.
527
+
528
+ It applies to params the action declares as an array, by plain name or with
529
+ the component's `reactive_scope` in front, and only at the top level. A group
530
+ declared one level down or inside nested attributes is ignored and keeps its
531
+ keyword default; `verbose_errors` logs it. The endpoint never takes the key it
532
+ writes from the request. It looks the name up in the declaration and writes
533
+ the declared key, so a request can't create a param the action didn't ask for.
534
+
535
+ A blank entry (`params[name][]=""`) would have been the other way, but the
536
+ schema reads `[""]` per element type, and for a `[:file]` param behind
537
+ `has_many_attached` that is the difference between "not sent" and "remove the
538
+ attachments".
539
+
540
+ `post_reactive_multipart` takes `empty_groups:` so a request spec can send a
541
+ cleared group the way the client does. `ParamSchema#array_param(name)`
542
+ returns the declared key for an array param, or `nil`.
543
+
544
+ `reactive_persist` drafts such a group as the list of ticked values and
545
+ restores exactly those boxes; before, the draft held one boolean and the
546
+ restore ticked every box of the group. Whether the server already rendered a
547
+ box ticked — in which case it keeps its say and the draft yields — is decided
548
+ once before the restore walks the controls, because the walk writes `checked`
549
+ as it goes and asking from inside it would read the restore's own work: the
550
+ first box it ticks would make every later box of the group look
551
+ server-rendered, and a draft of two values would come back as one. A
552
+ non-checkbox control sharing the group's name additionally threw inside the
553
+ draft write, which is swallowed — the root then persisted nothing at all,
554
+ silently. On restore, such a control keeps what the server rendered whenever
555
+ the group has two or more contributors: the list records the values, not
556
+ which control each one came from, so replaying it would paste `freeform,news`
557
+ into a text field, an editor, or a contenteditable. A group of ONE
558
+ contributor has no such ambiguity — its single entry can only have come from
559
+ that control — so a plain field whose name merely ends in `[]`, the usual
560
+ shape for a list JS maintains, keeps its draft exactly as it did before
561
+ groups existed. A `<select multiple>` reads a list by matching option values,
562
+ which is only sound when the list is its own: sharing a group with another
563
+ contributor, it too keeps what the server rendered, since a text value that
564
+ happens to equal an option would otherwise select it.
565
+
566
+ Drafts written before this release are not discarded, but their group key is
567
+ no longer applied to the controls that read a list: it holds one boolean (or,
568
+ in a mixed group, whichever control wrote last), and applying that kept
569
+ causing damage for as long as the draft lived — by default seven days after
570
+ the upgrade. The damage differed by control. A checkbox group came back fully
571
+ ticked. A `<select multiple>` sharing the group's name lost its rendered
572
+ selection instead, because under `restore: "always"` the select branch skips
573
+ the "the server had a say" check and matches `Set{"true"}` against its
574
+ options, where nothing matches. The next snapshot replaces the key with the
575
+ list. Only that one key changed meaning, which is why `PERSIST_VERSION` stays
576
+ where it is: bumping it would also throw away the drafted prose of every form
577
+ that has no checkbox group at all.
578
+
579
+ - **`reply.pending` kept its settle handle under
580
+ `enqueue_after_transaction_commit = true` (#254).** The handle was captured in
581
+ `Phlex::Reactive::Settles#serialize`, which reads a thread-local that lives
582
+ only for the duration of `reply.pending`'s enqueue block. Under Rails'
583
+ `ActiveJob::Base.enqueue_after_transaction_commit = true` (the 7.2+
584
+ recommended setting) `job.enqueue` is deferred to
585
+ `ActiveRecord.after_all_transactions_commit`, and the endpoint runs every
586
+ action inside a transaction — so `serialize` always ran AFTER the block had
587
+ exited. The job serialized with no `phlex_reactive_settle` key,
588
+ `reactive_settle` was a no-op, and the row sat shimmering until someone
589
+ reloaded. The handle is now captured when the job INSTANCE is created
590
+ (`perform_later` → `job_or_instantiate` → `new`), which is synchronous inside
591
+ the block whether or not the enqueue itself is deferred; `serialize` prefers
592
+ the captured handle and still falls back to the thread-local. Both the
593
+ `job:`/`args:` sugar (narrowing per record, so failures stay attributable) and
594
+ the block form are covered, and a retry re-enqueue keeps its handle. Jobs
595
+ enqueued outside any `reply.pending` still carry nothing.
596
+
444
597
  - **`rake release` bumps the pin in every tracked lockfile (#247, #253).** Since
445
598
  #246 the gem root's `Gemfile.lock` is committed alongside `docs/Gemfile.lock`,
446
599
  and both pin `phlex-reactive` by local path — so a version bump that left them
data/README.md CHANGED
@@ -396,7 +396,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
396
396
  | `reactive_listnav("[role=option]")` | The **standalone** combobox keyboard wiring (Arrow/Enter/Escape) for an input that fires **no action** — the preload-and-filter case. Same behavior as `on(…, listnav:)`, minus the POST. |
397
397
  | `reactive_tags(:tags)` | **Tag-chip input** (the combobox/tags widget): spread onto the root and name the hidden field that stores the **comma-joined** value — the client maintains that field + the chip list entirely client-side (form state, zero round trips), rebuilding chips from your server-owned `<template>`. Composes with `reactive_filter` (type to narrow) and `reactive_listnav` (Enter picks the highlighted option). `name:` is the escape hatch — a **verbatim** wire name (`name: "user[tags]"`, never re-scoped), the form-builder case. See [Tag-chip input](#tag-chip-input-reactive_tags). |
398
398
  | `reactive_tags_add` / `reactive_tags_option(tag)` / `reactive_tags_remove(tag)` | The tags triggers, all **client-only**: `reactive_tags_add` on the query input adds the typed text on Enter (mix it **after** `reactive_listnav`; Enter never submits the enclosing form); `reactive_tags_option` makes a preloaded suggestion add its declared tag on click; `reactive_tags_remove` is a chip's remove button (no arg inside the template — the client fills the tag per chip). |
399
- | `reactive_compute :name, inputs: { title: :string, qty: :number }, outputs:` | **Typed** inputs: a `:string` reaches the JS reducer raw, a `:number` is coerced through `Number`. The array form (`inputs: %i[a b]`) stays all-numeric; the **permit-style** form (`inputs: [:qty, title: :string]`) mixes both — bare symbols default `:number`, a trailing hash types the exceptions. `outputs:` is the field allowlist; a reducer-result key also paints any owned `reactive_text` node by presence and any `mirror:` id, so an `outputs:` entry that exists only to reach a text node is redundant (harmless — a widening). |
399
+ | `reactive_compute :name, inputs: { title: :string, qty: :number }, outputs:` | **Typed** inputs: a `:string` reaches the JS reducer raw, a `:number` is coerced through `Number`, a `:boolean` is a checkbox's checked state. The array form (`inputs: %i[a b]`) stays all-numeric; the **permit-style** form (`inputs: [:qty, title: :string]`) mixes both — bare symbols default `:number`, a trailing hash types the exceptions. `outputs:` is the field allowlist; a reducer-result key also paints any owned `reactive_text` node by presence and any `mirror:` id, so an `outputs:` entry that exists only to reach a text node is redundant (harmless — a widening). |
400
400
  | `reactive_compute :name, ..., mirror: { sum: "#summary-sum" }` | **Cross-root text mirrors**: paint a compute value into declared, id-allowlisted nodes **outside** the reactive root (a recap in another tab pane) via `textContent` — no bespoke listener. See [Cross-root mirrors](#cross-root-mirrors-mirror--painting-a-recap-outside-the-root). |
401
401
  | `reactive_dirty` / `reactive_dirty warn_unsaved: true` / `reactive_dirty only: %i[...]` | **Dirty tracking**, declared once at the class level, against the DOM's own `defaultValue`/`defaultChecked`/`defaultSelected` — no client state. Marks changed fields + the root `data-reactive-dirty`; `warn_unsaved:` arms a `beforeunload`/`turbo:before-visit` guard; `only:` scopes tracking to named fields. Style with `[data-reactive-dirty]`. See [Dirty-field tracking](#dirty-field-tracking-reactive_dirty). |
402
402
  | `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
@@ -491,12 +491,55 @@ def view_template
491
491
  end
492
492
  ```
493
493
 
494
- > **One multipart caveat:** `FormData` can't carry an *empty* array or hash, so on
495
- > the multipart (file-present) path an empty `[]`/`{}` param is **omitted** and the
496
- > action's keyword default applies — it does **not** arrive as an explicit empty
497
- > collection the way it does over JSON. If you rely on sending `tags: []` to clear
498
- > a collection, send that action *without* a file (the JSON path). A non-empty
499
- > nested/array param rides along fine next to a file.
494
+ > **Clearing a checkbox group next to a file.** When the reactive root holds a
495
+ > file, the action goes out as `FormData`, and `FormData` has no way to write an
496
+ > empty array. A group with values is fine: each value goes out as
497
+ > `params[name][]`. A group with nothing ticked would simply be missing, so the
498
+ > client lists its name in a separate field, `empty_groups[]`, and the endpoint
499
+ > sets that param to `[]`. The action gets the same value it would get over JSON.
500
+ >
501
+ > This works for any param the action declares as an array, by its plain name or
502
+ > with the component's `reactive_scope` in front. A `features[]` group is
503
+ > announced as `features`, and a `todo[tags][]` group under `reactive_scope :todo`
504
+ > as `todo[tags]`. Under a scope you write the group's name by hand, because
505
+ > `reactive_field(:"tags[]")` gives `todo[tags[]]`, which isn't read as a group:
506
+ >
507
+ > ```ruby
508
+ > input(type: "checkbox", name: "todo[tags][]", value: "ruby") # reactive_scope :todo
509
+ > ```
510
+ >
511
+ > The endpoint only fills a key the body didn't send, and only at the top level.
512
+ > A group declared one level down (`params: { project: { features: [:string] } }`)
513
+ > or inside nested attributes is left alone, and the action gets its keyword
514
+ > default as before. With `verbose_errors` on, the log tells you when that happens.
515
+ >
516
+ > Other empty `[]` or `{}` params are still left out of a form body. If you rely
517
+ > on `tags: []` to clear something that isn't a `[]` group, send that action
518
+ > without a file.
519
+
520
+ **Checkbox groups.** Controls sharing a name that ends in `[]` are collected
521
+ as an **array of the chosen values** — a ticked box contributes its `value`, an
522
+ unticked one nothing, a `<select multiple>` its selected options. Nothing ticked is
523
+ an empty array, not a missing key, so an action can tell a cleared group from one
524
+ that never rendered, over a form body as well (see above). Declare it as an
525
+ array type:
526
+
527
+ ```ruby
528
+ action :save, params: { features: [:string] } # <input type="checkbox" name="features[]" value="news">
529
+ ```
530
+
531
+ Three shapes keep their own meaning: a lone checkbox without `[]` stays the
532
+ documented yes/no boolean; a radio group keeps its single checked value, `[]` or
533
+ not; and a hidden input sharing a name with a checkbox is that box's **companion**
534
+ (Rails' `check_box` emits one, carrying the `unchecked_value`) and contributes
535
+ nothing. A hidden input *without* a same-named checkbox is an ordinary value — the
536
+ usual shape for a list maintained by JS.
537
+
538
+ The suffix is the only trigger, and it applies to a single control too: a lone
539
+ `<input type="text" name="tags[]">` posts `["abc"]` where it used to post
540
+ `"abc"`. Declare such a param as an array type (`tags: [:string]`) — against a
541
+ flat `tags: :string` the array coerces to its literal `to_s`. If the `[]` was
542
+ never meant as a list, drop it from the name.
500
543
 
501
544
  **Array & nested params.** Wrap a type in an array for an array param, or a hash
502
545
  schema in an array for Rails-style nested attributes — so one reactive action can
@@ -1244,6 +1287,29 @@ setComputeReducer("preview", ({ title }) => ({
1244
1287
  **permit-style form** (`inputs: [:qty, title: :string]`) combines both in one
1245
1288
  declaration — bare symbols default to `:number`, a trailing hash types the
1246
1289
  exceptions.
1290
+ - **Checkboxes and radios read their checked state.** A checkbox's `.value` is a
1291
+ constant, so a compute reads whether the box is ticked instead, coerced by the
1292
+ declared type: `1`/`0` as a `:number` (and in the array form),
1293
+ `"true"`/`"false"` as a `:string`, `true`/`false` as a **`:boolean`**. The
1294
+ checkbox wins over the hidden companion Rails' `check_box` renders before it.
1295
+ A radio group reads its checked radio's value (`""`, or `0` as a number, when
1296
+ none is checked). A `:boolean` on any other control is `false` for `""`, `"0"`
1297
+ and `"false"`.
1298
+
1299
+ ```ruby
1300
+ reactive_compute :total, inputs: [:price, { gift_wrap: :boolean }], outputs: %i[total]
1301
+ ```
1302
+
1303
+ ```js
1304
+ setComputeReducer("total", ({ price, gift_wrap }) => ({ total: price + (gift_wrap ? 25 : 0) }))
1305
+ ```
1306
+
1307
+ An **output** that resolves to a checkbox sets `checked` from the result's
1308
+ truthiness, and one that resolves to a radio group checks the radio carrying
1309
+ the result (a value no radio carries clears the group). Neither rewrites a
1310
+ `value` attribute, so what the control submits is unchanged. A `[]`-named
1311
+ checkbox *group* is not a compute input — compute values are scalars; declare
1312
+ each box under its own name.
1247
1313
  - **`reactive_text(:name, initial)`** mirrors a value into a **text node** via
1248
1314
  `textContent` (XSS-safe by construction). Every reducer-result key paints any
1249
1315
  matching sink: an owned **field** if declared in `outputs:`, any owned
@@ -2591,10 +2657,17 @@ bug). It emits:
2591
2657
 
2592
2658
  - **The handle rides ActiveJob metadata, not `perform`'s arity.** `reply.pending`
2593
2659
  installs it in a thread-local and runs your enqueue inside it;
2594
- `Settles#serialize` copies it into the job's metadata. So **every other caller
2595
- of the same job — a nightly sweep, a webhook — keeps working unchanged**, and
2596
- `reactive_settle` is simply a no-op there. That is load-bearing: these jobs
2597
- almost always have non-UI callers.
2660
+ `Settles#initialize` captures it onto the job instance and `#serialize` copies
2661
+ it into the job's metadata. So **every other caller of the same job — a
2662
+ nightly sweep, a webhook — keeps working unchanged**, and `reactive_settle` is
2663
+ simply a no-op there. That is load-bearing: these jobs almost always have
2664
+ non-UI callers.
2665
+ - **It works under `enqueue_after_transaction_commit = true`.** Rails defers that
2666
+ enqueue to `ActiveRecord.after_all_transactions_commit`, and the endpoint runs
2667
+ your action inside a transaction — so the enqueue (and its `serialize`) happens
2668
+ *after* the block has exited. The capture is therefore at job
2669
+ **instantiation**, which is synchronous inside the block either way. Nothing to
2670
+ configure.
2598
2671
  - **A job that raises still clears the pending state — when it can attribute
2599
2672
  it.** The `job:`/`args:` form enqueues one job per record and narrows each
2600
2673
  job's handle to *that record's* target, so a failure clears exactly its row
@@ -3215,8 +3288,10 @@ endpoint maps it to 403). Matchers: `have_reactive_replace`,
3215
3288
  refresh so a reply that would silently break the next click fails your test.
3216
3289
 
3217
3290
  **HTTP helpers** — `post_reactive_action(component_or_class, act, params:, payload:)`
3218
- and `post_reactive_multipart(...)` POST a signed token to
3219
- `Phlex::Reactive.action_path` exactly as the client does. **Token minting** —
3291
+ and `post_reactive_multipart(..., empty_groups: [])` POST a signed token to
3292
+ `Phlex::Reactive.action_path` exactly as the client does; `empty_groups:` lists
3293
+ the groups the client cleared by name, without the `[]`, the way a form body
3294
+ sends them. **Token minting** —
3220
3295
  `reactive_token_for(component_or_class, payload = {})`.
3221
3296
 
3222
3297
  > `verbose_errors` defaults ON in test (it changes only an error BODY, never a
@@ -556,6 +556,7 @@ module Phlex
556
556
  def coerce_params(action_def, component_class: nil, action_name: nil)
557
557
  dropped = Phlex::Reactive.verbose_errors ? [] : nil
558
558
  raw = unwrap_scope(params.fetch(:params, {}), component_class)
559
+ raw = apply_empty_groups(raw, action_def.schema, component_class, dropped) if params[:empty_groups]
559
560
 
560
561
  coerced = action_def.schema.coerce(raw, dropped)
561
562
  log_dropped_params(dropped, action_def.params, component_class, action_name)
@@ -570,7 +571,7 @@ module Phlex
570
571
  # raw params pass through untouched (unscoped components + nested_attributes
571
572
  # shapes are unaffected).
572
573
  def unwrap_scope(raw, component_class)
573
- scope = component_class.reactive_scope if component_class.respond_to?(:reactive_scope)
574
+ scope = reactive_scope_of(component_class)
574
575
  return raw unless scope
575
576
 
576
577
  # At the endpoint `raw` is ActionController::Parameters, so `raw[scope]` is
@@ -580,6 +581,68 @@ module Phlex
580
581
  nested.is_a?(Hash) || nested.is_a?(ActionController::Parameters) ? nested : raw
581
582
  end
582
583
 
584
+ # Issue #258: a form body cannot carry an empty array, so the client
585
+ # ANNOUNCES a cleared `[]` group — its key absent from params, its name in
586
+ # `empty_groups[]` beside token/act/params. Those names are written back as
587
+ # empty arrays, the value the JSON path sends outright. The README documents
588
+ # what the rule accepts and what it leaves alone.
589
+ #
590
+ # The key written is whatever `array_param` hands back, so it is always a
591
+ # key the DECLARATION holds — never the announced name, which is only ever
592
+ # compared. Runs AFTER unwrap_scope so the params root is the only target:
593
+ # no node is built to reach a group, hence no nested-attributes row.
594
+ def apply_empty_groups(raw, schema, component_class, dropped)
595
+ names = params[:empty_groups]
596
+ return raw unless names.is_a?(Array)
597
+ # `raw` is whatever arrived: a String from `params=x` in a query string
598
+ # normalises to {} in coerce, but writing into it would raise.
599
+ return raw unless raw.is_a?(Hash) || raw.is_a?(ActionController::Parameters)
600
+
601
+ scope = reactive_scope_of(component_class)
602
+ # Flat is enough — only the root is written — and it keeps the filled key
603
+ # off the request's own params object.
604
+ raw = raw.dup
605
+ names.each do
606
+ key = announced_key(it, schema, scope)
607
+ # Its OWN reason, not :undeclared: that one routes through the #16/#21
608
+ # shape hints, which would read `empty_groups` as a param and advise
609
+ # nesting the group under it. `dropped` is nil unless verbose_errors.
610
+ next dropped&.<<(["empty_groups #{it}", ANNOUNCED_UNDECLARED]) unless key
611
+
612
+ raw[key] = [] unless raw.key?(key)
613
+ end
614
+ raw
615
+ end
616
+
617
+ ANNOUNCED_UNDECLARED = :"undeclared — the action declares no array param by that name"
618
+ private_constant :ANNOUNCED_UNDECLARED
619
+
620
+ # The declared key an announced name resolves to, or nil — bare (`tags`) or
621
+ # carrying the component's scope (`todo[tags]`), the two shapes the client
622
+ # emits for one group. Anything else resolves to nothing, whatever its
623
+ # type: array_param only answers with a key it already holds.
624
+ def announced_key(name, schema, scope)
625
+ schema.array_param(unscoped_group_name(name.to_s, scope))
626
+ end
627
+
628
+ # `todo[tags]` => `tags` under `reactive_scope :todo`. One level off the
629
+ # front, never a walk — a deeper name (todo[a][tags]) keeps its remaining
630
+ # brackets and simply fails the lookup.
631
+ def unscoped_group_name(name, scope)
632
+ return name unless scope
633
+
634
+ prefix = "#{scope}["
635
+ return name unless name.start_with?(prefix) && name.end_with?("]")
636
+
637
+ name.delete_prefix(prefix).delete_suffix("]")
638
+ end
639
+
640
+ # Read the way unwrap_scope reads it — the peel and this strip have to
641
+ # agree on the scope or an announced name lands at the wrong depth.
642
+ def reactive_scope_of(component_class)
643
+ component_class.reactive_scope if component_class.respond_to?(:reactive_scope)
644
+ end
645
+
583
646
  # ---- verbose_errors dropped-param logging --------------------------
584
647
  # ParamSchema collects the dropped entries; the controller formats the ONE
585
648
  # warn line (with the #16/#21 shape hints). Everything below runs ONLY when
@@ -615,7 +678,7 @@ module Phlex
615
678
  segments = bracket_path(path)
616
679
  if segments.length > 1
617
680
  leaf = segments.last
618
- return unless schema.key?(leaf.to_sym)
681
+ return unless declared_key?(schema, leaf)
619
682
 
620
683
  "schema declares :#{leaf} at top level; nested schemas look like " \
621
684
  "{ #{segments.first}: { #{leaf}: :string } }"
@@ -628,12 +691,18 @@ module Phlex
628
691
  end
629
692
  end
630
693
 
694
+ # A schema declares `name` whether it was written with a symbol or a
695
+ # string key; ParamSchema.compile keeps whichever the author used.
696
+ def declared_key?(schema, name)
697
+ schema.key?(name.to_sym) || schema.key?(name.to_s)
698
+ end
699
+
631
700
  # The first schema key whose nested hash (or array-of-hash element
632
701
  # schema) declares `name` one level down.
633
702
  def nested_declaration_of(name, schema)
634
703
  schema.find do |_key, type|
635
704
  inner = type.is_a?(Array) ? type.first : type
636
- inner.is_a?(Hash) && inner.key?(name.to_sym)
705
+ inner.is_a?(Hash) && declared_key?(inner, name)
637
706
  end&.first
638
707
  end
639
708
 
@@ -28,13 +28,21 @@
28
28
  // issue #104) arrive as the RAW string. `reactive_compute :x, inputs:
29
29
  // { title: :string, qty: :number }` is what selects per-input types;
30
30
  // `inputs: %i[a b]` stays all-numeric (backward compatible).
31
+ // A CHECKBOX arrives as its checked state, never its constant
32
+ // .value (issue #262): 1/0 untyped or :number, "true"/"false" as a
33
+ // :string, true/false as a :boolean. A radio group arrives as its
34
+ // checked radio's value ("" / 0 when none is checked).
31
35
  // meta — { changed }: the name (string) of the declared input the
32
36
  // triggering event edited, or null (a direct recompute() call, or a
33
37
  // target this root doesn't own / didn't declare as an input).
34
38
  //
35
39
  // OUTPUTS may be a form FIELD or a TEXT NODE (issue #104). An output whose name
36
40
  // matches an owned control writes its .value (+ the change-guarded input
37
- // dispatch below). An output with NO matching field writes textContent to every
41
+ // dispatch below) — or, for a checkbox or a radio group, its CHECKED state
42
+ // (issue #262): a truthy result ticks the box ("", "0", "false", 0 and false
43
+ // untick it), and a radio group checks the radio carrying the result. Their
44
+ // value attributes are never rewritten, so what they submit is unchanged.
45
+ // An output with NO matching field writes textContent to every
38
46
  // owned [data-reactive-text="<name>"] node (reactive_text(:name)) — XSS-safe by
39
47
  // construction, change-guarded, NO input dispatch (a text node has no listener
40
48
  // contract). A declared INPUT also mirrors into its own text node on every
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["compute.js"],
4
4
  "sourcesContent": [
5
- "// The client-side compute (data-binding) registry — the \"instant\" half of the\n// new/unpersisted-record UX.\n//\n// A record-backed reactive component round-trips every change to the server\n// (the signed identity re-finds the record; the server re-renders). A NEW,\n// unpersisted record has no such server truth to re-render against on every\n// keystroke — the classic answer is a bespoke Stimulus controller doing the math\n// in the browser (carlqvist's new_order_controller.js). This registry lets that\n// math be a DECLARED part of the component instead: `reactive_compute :name,\n// inputs:, outputs:` (Ruby) names a reducer registered here, and the generic\n// reactive controller runs it on `input` — writing the outputs with NO round\n// trip. When the component ALSO carries on(...) (a persisted record, or a draft\n// you sync), the debounced POST reconciles from the authoritative server reply.\n//\n// The seam mirrors confirm.js: a settable registry with a lookup the controller\n// calls. Register once at boot:\n//\n// import { setComputeReducer } from \"phlex/reactive/compute\"\n// setComputeReducer(\"payment_split\", ({ allowance, cash, leasing, total }) => ({\n// allowance, leasing, cash: total - allowance - leasing,\n// }))\n//\n// The reducer's signature is (values, meta):\n//\n// values — a plain object of { inputName: value } over the declared inputs.\n// Untyped inputs (the array form) AND :number-typed inputs arrive as\n// Numbers (blank/NaN → 0); :string-typed inputs (the hash form,\n// issue #104) arrive as the RAW string. `reactive_compute :x, inputs:\n// { title: :string, qty: :number }` is what selects per-input types;\n// `inputs: %i[a b]` stays all-numeric (backward compatible).\n// meta — { changed }: the name (string) of the declared input the\n// triggering event edited, or null (a direct recompute() call, or a\n// target this root doesn't own / didn't declare as an input).\n//\n// OUTPUTS may be a form FIELD or a TEXT NODE (issue #104). An output whose name\n// matches an owned control writes its .value (+ the change-guarded input\n// dispatch below). An output with NO matching field writes textContent to every\n// owned [data-reactive-text=\"<name>\"] node (reactive_text(:name)) — XSS-safe by\n// construction, change-guarded, NO input dispatch (a text node has no listener\n// contract). A declared INPUT also mirrors into its own text node on every\n// input via an always-run pass — so reactive_text(:title) is a live field\n// preview with NO registered reducer at all.\n//\n// It returns a plain object of { outputName: value } — only the outputs it\n// names are written, so it can leave the edited field (and its caret)\n// untouched. A one-argument reducer keeps working unchanged (it just ignores\n// meta). `changed` is what makes a MULTI-WAY / MUTUAL rebalance expressible as\n// one reducer (issue #75) — branch on which field the user edited:\n//\n// setComputeReducer(\"three_way_split\", ({ field_a, field_b, field_c, total }, { changed }) => {\n// if (changed === \"field_c\") return { field_a: total - field_c - field_b }\n// return { field_c: total - field_a - field_b }\n// })\n//\n// Output writes are CHANGE-GUARDED: the controller writes a field and\n// dispatches a bubbling `input` event on it ONLY when the new value differs\n// from the field's current value (real browsers never fire `input` on a\n// programmatic .value write, so the controller dispatches explicitly — that's\n// what drives a chained summary repaint, matching the server's set_value +\n// dispatch(\"input\") contract). Returning the SAME value a field already holds\n// is skipped entirely — no write, no event — which is why a reducer with\n// overlapping inputs/outputs (like payment_split above) settles instead of\n// re-entering itself forever.\n//\n// CONVERGENCE REQUIREMENT: because an output write dispatches a REAL input\n// event (issue #76), recompute re-enters with changed = that OUTPUT field's\n// name (when it's also a declared input). A branching reducer must therefore\n// be convergent: the re-entrant pass must compute values EQUAL to what the\n// first pass already wrote to the DOM, so the change guard settles the chain.\n// The three_way_split above is: after `changed === \"field_a\"` writes field_c,\n// the re-entrant `changed === \"field_c\"` pass derives field_a back to the\n// value it already holds — no write, no event, settled in one bounce.\n\n// THE RESERVED `$ops` OUTPUT (issue #226). Besides field/text outputs, a\n// reducer may return the reserved key `$ops` holding a chain of client DOM\n// ops — built with the `ops` builder below, or a raw [[name, args], ...]\n// array. The controller consumes it as a PHASE 4 of the single-pass write set:\n// the ops run AFTER the field writes, text sinks, and phase-3 input dispatches\n// settle, through the SAME frozen CLIENT_OPS whitelist on_client uses (an\n// unknown op warns + is skipped). `null`/`undefined`/absent = no effect.\n//\n// RISING-EDGE semantics, keyed on CONTENT: the chain runs only when it\n// DIFFERS from the previous pass's chain (including from \"absent\"), and only\n// on EVENT-DRIVEN passes. Returning the SAME chain again is settled — no\n// re-fire (a 7th keystroke capped back to the same complete value can't\n// re-submit), mirroring the change-guarded field writes. Returning a\n// DIFFERENT chain fires again — a multi-box reducer advancing focus\n// box-by-box emits a new focus target per digit, each a new intent. The\n// connect/morph SEED pass (issue #199 — recompute with no event) ARMS the\n// latch but never fires — a form re-rendered with an already-complete value\n// (a validation-error morph, a browser restore) must not auto-fire; that is\n// what breaks the submit → error re-render → re-seed → submit loop. A later\n// pass returning no $ops re-arms. The canonical use — a one-time-code field\n// that normalizes on input and commits when complete:\n//\n// import { setComputeReducer, ops } from \"phlex/reactive/compute\"\n// setComputeReducer(\"otp\", ({ code }) => {\n// const digits = code.replace(/\\D/g, \"\").slice(0, 6)\n// return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }\n// })\n//\n// `submit` commits the target's own form via requestSubmit() — the real submit\n// event fires, so an on(:verify, event: \"submit\") interception (or a native/\n// Turbo form) handles it exactly like a user submit. submit/focus are\n// ACTOR-ONLY: usable here and in on_client/reply.js, refused in broadcasts.\n// paste_into (issue #228) is actor-only too but DELIBERATELY absent from this\n// builder: a reducer runs on every input event, and a clipboard read per\n// keystroke (each changed chain fires) would spam permission prompts. Use it\n// from on_client / reply.js / reactive_on_complete instead; a raw\n// [[\"paste_into\", …]] pair still interprets if you truly need it.\n\nconst reducers = new Map()\n\n// Register (or replace) the reducer for `key`. `fn` is\n// (values: Record<string, number>, meta: { changed: string | null })\n// => Record<string, unknown>.\nexport function setComputeReducer(key, fn) {\n reducers.set(key, fn)\n}\n\n// Look up a registered reducer; undefined when none — the controller then makes\n// #recompute a no-op rather than throwing (a missing reducer must not break the\n// page; it just means no client-side binding for that root).\nexport function computeReducer(key) {\n return reducers.get(key)\n}\n\n// Test seam: clear the registry so a reducer registered in one test can't leak.\nexport function __resetComputeRegistryForTest() {\n reducers.clear()\n}\n\n// --- The reducer-side op-chain builder (issue #226) --------------------------\n//\n// A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder (minus\n// paste_into — see the actor-only note above): verbs carry\n// the WIRE op names (snake_case) and append [name, args] pairs; every verb\n// returns a NEW instance, so a chain held in a constant can never be mutated by\n// later use. `.ops` exposes the raw [[name, args], ...] list the controller\n// interprets (and toJSON serializes it, so a chain can also feed a hand-built\n// ops attr). Targets: a CSS selector string, or omit for \"@root\" (the\n// component's own root). No build-time attr validation here — the interpreter's\n// allowlist (guardAttr) is the enforcement point; this builder only shapes the\n// wire.\nconst ROOT_SENTINEL = \"@root\"\n\nfunction targetArgs(to, { global, transition } = {}) {\n const args = { to: to ?? ROOT_SENTINEL }\n if (global) args.global = true\n if (transition) args.transition = normalizeTransition(transition)\n return args\n}\n\n// Named legs { during, from, to } → the [during, from, to] wire array (the\n// issue #186 vocabulary). Loud at authoring time, like the Ruby builder.\n// Frozen, like every nested payload — the chain's immutability contract must\n// hold all the way down (the Ruby twin freezes its legs array too).\nfunction normalizeTransition(transition) {\n const named =\n transition && typeof transition === \"object\" && !Array.isArray(transition) &&\n [\"during\", \"from\", \"to\"].every((k) => k in transition)\n if (!named) throw new Error(\"[phlex-reactive] ops transition takes named legs { during, from, to }\")\n return Object.freeze([String(transition.during), String(transition.from), String(transition.to)])\n}\n\nclass OpsChain {\n constructor(list = Object.freeze([])) {\n this.ops = list\n Object.freeze(this)\n }\n\n show(to, opts) {\n return this.#append(\"show\", targetArgs(to, opts))\n }\n\n hide(to, opts) {\n return this.#append(\"hide\", targetArgs(to, opts))\n }\n\n toggle(to, opts) {\n return this.#append(\"toggle\", targetArgs(to, opts))\n }\n\n add_class(to, classes, opts) {\n return this.#append(\"add_class\", classArgs(to, classes, opts))\n }\n\n remove_class(to, classes, opts) {\n return this.#append(\"remove_class\", classArgs(to, classes, opts))\n }\n\n toggle_class(to, classes, opts) {\n return this.#append(\"toggle_class\", classArgs(to, classes, opts))\n }\n\n set_attr(to, name, value, opts) {\n return this.#append(\"set_attr\", { ...targetArgs(to, opts), name: String(name), value: String(value) })\n }\n\n remove_attr(to, name, opts) {\n return this.#append(\"remove_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n toggle_attr(to, name, opts) {\n return this.#append(\"toggle_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n focus(to, opts) {\n return this.#append(\"focus\", targetArgs(to, opts))\n }\n\n focus_first(to, opts) {\n return this.#append(\"focus_first\", targetArgs(to, opts))\n }\n\n text(to, value, opts) {\n return this.#append(\"text\", { ...targetArgs(to, opts), value: String(value ?? \"\") })\n }\n\n dispatch(name, { to, detail, global } = {}) {\n const args = { name: String(name), to: to ?? ROOT_SENTINEL, detail: detail ?? {} }\n if (global) args.global = true\n return this.#append(\"dispatch\", args)\n }\n\n submit(to, opts) {\n return this.#append(\"submit\", targetArgs(to, opts))\n }\n\n toJSON() {\n return this.ops\n }\n\n #append(name, args) {\n return new OpsChain(Object.freeze([...this.ops, Object.freeze([name, Object.freeze(args)])]))\n }\n}\n\n// classes: one class string or an array of them (never whitespace-split — a\n// classList token can't contain spaces, so splitting would only mask a bug).\n// Loud on an empty/missing list, and frozen (the Ruby twin freezes its class\n// list too) — a chain held in a constant must stay immutable all the way down.\nfunction classArgs(to, classes, opts) {\n const list = classes == null ? [] : (Array.isArray(classes) ? classes : [classes]).map(String)\n if (list.length === 0) throw new Error(\"[phlex-reactive] a class op needs at least one class\")\n return { ...targetArgs(to, opts), classes: Object.freeze(list) }\n}\n\n// The shared empty chain — start every reducer effect from here:\n// $ops: done ? ops.dispatch(\"code:complete\").submit() : null\nexport const ops = new OpsChain()\n"
5
+ "// The client-side compute (data-binding) registry — the \"instant\" half of the\n// new/unpersisted-record UX.\n//\n// A record-backed reactive component round-trips every change to the server\n// (the signed identity re-finds the record; the server re-renders). A NEW,\n// unpersisted record has no such server truth to re-render against on every\n// keystroke — the classic answer is a bespoke Stimulus controller doing the math\n// in the browser (carlqvist's new_order_controller.js). This registry lets that\n// math be a DECLARED part of the component instead: `reactive_compute :name,\n// inputs:, outputs:` (Ruby) names a reducer registered here, and the generic\n// reactive controller runs it on `input` — writing the outputs with NO round\n// trip. When the component ALSO carries on(...) (a persisted record, or a draft\n// you sync), the debounced POST reconciles from the authoritative server reply.\n//\n// The seam mirrors confirm.js: a settable registry with a lookup the controller\n// calls. Register once at boot:\n//\n// import { setComputeReducer } from \"phlex/reactive/compute\"\n// setComputeReducer(\"payment_split\", ({ allowance, cash, leasing, total }) => ({\n// allowance, leasing, cash: total - allowance - leasing,\n// }))\n//\n// The reducer's signature is (values, meta):\n//\n// values — a plain object of { inputName: value } over the declared inputs.\n// Untyped inputs (the array form) AND :number-typed inputs arrive as\n// Numbers (blank/NaN → 0); :string-typed inputs (the hash form,\n// issue #104) arrive as the RAW string. `reactive_compute :x, inputs:\n// { title: :string, qty: :number }` is what selects per-input types;\n// `inputs: %i[a b]` stays all-numeric (backward compatible).\n// A CHECKBOX arrives as its checked state, never its constant\n// .value (issue #262): 1/0 untyped or :number, \"true\"/\"false\" as a\n// :string, true/false as a :boolean. A radio group arrives as its\n// checked radio's value (\"\" / 0 when none is checked).\n// meta — { changed }: the name (string) of the declared input the\n// triggering event edited, or null (a direct recompute() call, or a\n// target this root doesn't own / didn't declare as an input).\n//\n// OUTPUTS may be a form FIELD or a TEXT NODE (issue #104). An output whose name\n// matches an owned control writes its .value (+ the change-guarded input\n// dispatch below) — or, for a checkbox or a radio group, its CHECKED state\n// (issue #262): a truthy result ticks the box (\"\", \"0\", \"false\", 0 and false\n// untick it), and a radio group checks the radio carrying the result. Their\n// value attributes are never rewritten, so what they submit is unchanged.\n// An output with NO matching field writes textContent to every\n// owned [data-reactive-text=\"<name>\"] node (reactive_text(:name)) — XSS-safe by\n// construction, change-guarded, NO input dispatch (a text node has no listener\n// contract). A declared INPUT also mirrors into its own text node on every\n// input via an always-run pass — so reactive_text(:title) is a live field\n// preview with NO registered reducer at all.\n//\n// It returns a plain object of { outputName: value } — only the outputs it\n// names are written, so it can leave the edited field (and its caret)\n// untouched. A one-argument reducer keeps working unchanged (it just ignores\n// meta). `changed` is what makes a MULTI-WAY / MUTUAL rebalance expressible as\n// one reducer (issue #75) — branch on which field the user edited:\n//\n// setComputeReducer(\"three_way_split\", ({ field_a, field_b, field_c, total }, { changed }) => {\n// if (changed === \"field_c\") return { field_a: total - field_c - field_b }\n// return { field_c: total - field_a - field_b }\n// })\n//\n// Output writes are CHANGE-GUARDED: the controller writes a field and\n// dispatches a bubbling `input` event on it ONLY when the new value differs\n// from the field's current value (real browsers never fire `input` on a\n// programmatic .value write, so the controller dispatches explicitly — that's\n// what drives a chained summary repaint, matching the server's set_value +\n// dispatch(\"input\") contract). Returning the SAME value a field already holds\n// is skipped entirely — no write, no event — which is why a reducer with\n// overlapping inputs/outputs (like payment_split above) settles instead of\n// re-entering itself forever.\n//\n// CONVERGENCE REQUIREMENT: because an output write dispatches a REAL input\n// event (issue #76), recompute re-enters with changed = that OUTPUT field's\n// name (when it's also a declared input). A branching reducer must therefore\n// be convergent: the re-entrant pass must compute values EQUAL to what the\n// first pass already wrote to the DOM, so the change guard settles the chain.\n// The three_way_split above is: after `changed === \"field_a\"` writes field_c,\n// the re-entrant `changed === \"field_c\"` pass derives field_a back to the\n// value it already holds — no write, no event, settled in one bounce.\n\n// THE RESERVED `$ops` OUTPUT (issue #226). Besides field/text outputs, a\n// reducer may return the reserved key `$ops` holding a chain of client DOM\n// ops — built with the `ops` builder below, or a raw [[name, args], ...]\n// array. The controller consumes it as a PHASE 4 of the single-pass write set:\n// the ops run AFTER the field writes, text sinks, and phase-3 input dispatches\n// settle, through the SAME frozen CLIENT_OPS whitelist on_client uses (an\n// unknown op warns + is skipped). `null`/`undefined`/absent = no effect.\n//\n// RISING-EDGE semantics, keyed on CONTENT: the chain runs only when it\n// DIFFERS from the previous pass's chain (including from \"absent\"), and only\n// on EVENT-DRIVEN passes. Returning the SAME chain again is settled — no\n// re-fire (a 7th keystroke capped back to the same complete value can't\n// re-submit), mirroring the change-guarded field writes. Returning a\n// DIFFERENT chain fires again — a multi-box reducer advancing focus\n// box-by-box emits a new focus target per digit, each a new intent. The\n// connect/morph SEED pass (issue #199 — recompute with no event) ARMS the\n// latch but never fires — a form re-rendered with an already-complete value\n// (a validation-error morph, a browser restore) must not auto-fire; that is\n// what breaks the submit → error re-render → re-seed → submit loop. A later\n// pass returning no $ops re-arms. The canonical use — a one-time-code field\n// that normalizes on input and commits when complete:\n//\n// import { setComputeReducer, ops } from \"phlex/reactive/compute\"\n// setComputeReducer(\"otp\", ({ code }) => {\n// const digits = code.replace(/\\D/g, \"\").slice(0, 6)\n// return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }\n// })\n//\n// `submit` commits the target's own form via requestSubmit() — the real submit\n// event fires, so an on(:verify, event: \"submit\") interception (or a native/\n// Turbo form) handles it exactly like a user submit. submit/focus are\n// ACTOR-ONLY: usable here and in on_client/reply.js, refused in broadcasts.\n// paste_into (issue #228) is actor-only too but DELIBERATELY absent from this\n// builder: a reducer runs on every input event, and a clipboard read per\n// keystroke (each changed chain fires) would spam permission prompts. Use it\n// from on_client / reply.js / reactive_on_complete instead; a raw\n// [[\"paste_into\", …]] pair still interprets if you truly need it.\n\nconst reducers = new Map()\n\n// Register (or replace) the reducer for `key`. `fn` is\n// (values: Record<string, number>, meta: { changed: string | null })\n// => Record<string, unknown>.\nexport function setComputeReducer(key, fn) {\n reducers.set(key, fn)\n}\n\n// Look up a registered reducer; undefined when none — the controller then makes\n// #recompute a no-op rather than throwing (a missing reducer must not break the\n// page; it just means no client-side binding for that root).\nexport function computeReducer(key) {\n return reducers.get(key)\n}\n\n// Test seam: clear the registry so a reducer registered in one test can't leak.\nexport function __resetComputeRegistryForTest() {\n reducers.clear()\n}\n\n// --- The reducer-side op-chain builder (issue #226) --------------------------\n//\n// A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder (minus\n// paste_into — see the actor-only note above): verbs carry\n// the WIRE op names (snake_case) and append [name, args] pairs; every verb\n// returns a NEW instance, so a chain held in a constant can never be mutated by\n// later use. `.ops` exposes the raw [[name, args], ...] list the controller\n// interprets (and toJSON serializes it, so a chain can also feed a hand-built\n// ops attr). Targets: a CSS selector string, or omit for \"@root\" (the\n// component's own root). No build-time attr validation here — the interpreter's\n// allowlist (guardAttr) is the enforcement point; this builder only shapes the\n// wire.\nconst ROOT_SENTINEL = \"@root\"\n\nfunction targetArgs(to, { global, transition } = {}) {\n const args = { to: to ?? ROOT_SENTINEL }\n if (global) args.global = true\n if (transition) args.transition = normalizeTransition(transition)\n return args\n}\n\n// Named legs { during, from, to } → the [during, from, to] wire array (the\n// issue #186 vocabulary). Loud at authoring time, like the Ruby builder.\n// Frozen, like every nested payload — the chain's immutability contract must\n// hold all the way down (the Ruby twin freezes its legs array too).\nfunction normalizeTransition(transition) {\n const named =\n transition && typeof transition === \"object\" && !Array.isArray(transition) &&\n [\"during\", \"from\", \"to\"].every((k) => k in transition)\n if (!named) throw new Error(\"[phlex-reactive] ops transition takes named legs { during, from, to }\")\n return Object.freeze([String(transition.during), String(transition.from), String(transition.to)])\n}\n\nclass OpsChain {\n constructor(list = Object.freeze([])) {\n this.ops = list\n Object.freeze(this)\n }\n\n show(to, opts) {\n return this.#append(\"show\", targetArgs(to, opts))\n }\n\n hide(to, opts) {\n return this.#append(\"hide\", targetArgs(to, opts))\n }\n\n toggle(to, opts) {\n return this.#append(\"toggle\", targetArgs(to, opts))\n }\n\n add_class(to, classes, opts) {\n return this.#append(\"add_class\", classArgs(to, classes, opts))\n }\n\n remove_class(to, classes, opts) {\n return this.#append(\"remove_class\", classArgs(to, classes, opts))\n }\n\n toggle_class(to, classes, opts) {\n return this.#append(\"toggle_class\", classArgs(to, classes, opts))\n }\n\n set_attr(to, name, value, opts) {\n return this.#append(\"set_attr\", { ...targetArgs(to, opts), name: String(name), value: String(value) })\n }\n\n remove_attr(to, name, opts) {\n return this.#append(\"remove_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n toggle_attr(to, name, opts) {\n return this.#append(\"toggle_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n focus(to, opts) {\n return this.#append(\"focus\", targetArgs(to, opts))\n }\n\n focus_first(to, opts) {\n return this.#append(\"focus_first\", targetArgs(to, opts))\n }\n\n text(to, value, opts) {\n return this.#append(\"text\", { ...targetArgs(to, opts), value: String(value ?? \"\") })\n }\n\n dispatch(name, { to, detail, global } = {}) {\n const args = { name: String(name), to: to ?? ROOT_SENTINEL, detail: detail ?? {} }\n if (global) args.global = true\n return this.#append(\"dispatch\", args)\n }\n\n submit(to, opts) {\n return this.#append(\"submit\", targetArgs(to, opts))\n }\n\n toJSON() {\n return this.ops\n }\n\n #append(name, args) {\n return new OpsChain(Object.freeze([...this.ops, Object.freeze([name, Object.freeze(args)])]))\n }\n}\n\n// classes: one class string or an array of them (never whitespace-split — a\n// classList token can't contain spaces, so splitting would only mask a bug).\n// Loud on an empty/missing list, and frozen (the Ruby twin freezes its class\n// list too) — a chain held in a constant must stay immutable all the way down.\nfunction classArgs(to, classes, opts) {\n const list = classes == null ? [] : (Array.isArray(classes) ? classes : [classes]).map(String)\n if (list.length === 0) throw new Error(\"[phlex-reactive] a class op needs at least one class\")\n return { ...targetArgs(to, opts), classes: Object.freeze(list) }\n}\n\n// The shared empty chain — start every reducer effect from here:\n// $ops: done ? ops.dispatch(\"code:complete\").submit() : null\nexport const ops = new OpsChain()\n"
6
6
  ],
7
- "mappings": "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",
7
+ "mappings": "AAuHA,IAAM,EAAW,IAAI,IAKd,SAAS,CAAiB,CAAC,EAAK,EAAI,CACzC,EAAS,IAAI,EAAK,CAAE,EAMf,SAAS,CAAc,CAAC,EAAK,CAClC,OAAO,EAAS,IAAI,CAAG,EAIlB,SAAS,CAA6B,EAAG,CAC9C,EAAS,MAAM,EAejB,IAAM,EAAgB,QAEtB,SAAS,CAAU,CAAC,GAAM,SAAQ,cAAe,CAAC,EAAG,CACnD,IAAM,EAAO,CAAE,GAAI,GAAM,CAAc,EACvC,GAAI,EAAQ,EAAK,OAAS,GAC1B,GAAI,EAAY,EAAK,WAAa,EAAoB,CAAU,EAChE,OAAO,EAOT,SAAS,CAAmB,CAAC,EAAY,CAIvC,GAAI,EAFF,GAAc,OAAO,IAAe,UAAY,CAAC,MAAM,QAAQ,CAAU,GACzE,CAAC,SAAU,OAAQ,IAAI,EAAE,MAAM,CAAC,KAAM,KAAK,EAAU,GAC3C,MAAU,MAAM,uEAAuE,EACnG,OAAO,OAAO,OAAO,CAAC,OAAO,EAAW,MAAM,EAAG,OAAO,EAAW,IAAI,EAAG,OAAO,EAAW,EAAE,CAAC,CAAC,EAGlG,MAAM,CAAS,CACb,WAAW,CAAC,EAAO,OAAO,OAAO,CAAC,CAAC,EAAG,CACpC,KAAK,IAAM,EACX,OAAO,OAAO,IAAI,EAGpB,IAAI,CAAC,EAAI,EAAM,CACb,OAAO,KAAK,GAAQ,OAAQ,EAAW,EAAI,CAAI,CAAC,EAGlD,IAAI,CAAC,EAAI,EAAM,CACb,OAAO,KAAK,GAAQ,OAAQ,EAAW,EAAI,CAAI,CAAC,EAGlD,MAAM,CAAC,EAAI,EAAM,CACf,OAAO,KAAK,GAAQ,SAAU,EAAW,EAAI,CAAI,CAAC,EAGpD,SAAS,CAAC,EAAI,EAAS,EAAM,CAC3B,OAAO,KAAK,GAAQ,YAAa,EAAU,EAAI,EAAS,CAAI,CAAC,EAG/D,YAAY,CAAC,EAAI,EAAS,EAAM,CAC9B,OAAO,KAAK,GAAQ,eAAgB,EAAU,EAAI,EAAS,CAAI,CAAC,EAGlE,YAAY,CAAC,EAAI,EAAS,EAAM,CAC9B,OAAO,KAAK,GAAQ,eAAgB,EAAU,EAAI,EAAS,CAAI,CAAC,EAGlE,QAAQ,CAAC,EAAI,EAAM,EAAO,EAAM,CAC9B,OAAO,KAAK,GAAQ,WAAY,IAAK,EAAW,EAAI,CAAI,EAAG,KAAM,OAAO,CAAI,EAAG,MAAO,OAAO,CAAK,CAAE,CAAC,EAGvG,WAAW,CAAC,EAAI,EAAM,EAAM,CAC1B,OAAO,KAAK,GAAQ,cAAe,IAAK,EAAW,EAAI,CAAI,EAAG,KAAM,OAAO,CAAI,CAAE,CAAC,EAGpF,WAAW,CAAC,EAAI,EAAM,EAAM,CAC1B,OAAO,KAAK,GAAQ,cAAe,IAAK,EAAW,EAAI,CAAI,EAAG,KAAM,OAAO,CAAI,CAAE,CAAC,EAGpF,KAAK,CAAC,EAAI,EAAM,CACd,OAAO,KAAK,GAAQ,QAAS,EAAW,EAAI,CAAI,CAAC,EAGnD,WAAW,CAAC,EAAI,EAAM,CACpB,OAAO,KAAK,GAAQ,cAAe,EAAW,EAAI,CAAI,CAAC,EAGzD,IAAI,CAAC,EAAI,EAAO,EAAM,CACpB,OAAO,KAAK,GAAQ,OAAQ,IAAK,EAAW,EAAI,CAAI,EAAG,MAAO,OAAO,GAAS,EAAE,CAAE,CAAC,EAGrF,QAAQ,CAAC,GAAQ,KAAI,SAAQ,UAAW,CAAC,EAAG,CAC1C,IAAM,EAAO,CAAE,KAAM,OAAO,CAAI,EAAG,GAAI,GAAM,EAAe,OAAQ,GAAU,CAAC,CAAE,EACjF,GAAI,EAAQ,EAAK,OAAS,GAC1B,OAAO,KAAK,GAAQ,WAAY,CAAI,EAGtC,MAAM,CAAC,EAAI,EAAM,CACf,OAAO,KAAK,GAAQ,SAAU,EAAW,EAAI,CAAI,CAAC,EAGpD,MAAM,EAAG,CACP,OAAO,KAAK,IAGd,EAAO,CAAC,EAAM,EAAM,CAClB,OAAO,IAAI,EAAS,OAAO,OAAO,CAAC,GAAG,KAAK,IAAK,OAAO,OAAO,CAAC,EAAM,OAAO,OAAO,CAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAEhG,CAMA,SAAS,CAAS,CAAC,EAAI,EAAS,EAAM,CACpC,IAAM,EAAO,GAAW,KAAO,CAAC,GAAK,MAAM,QAAQ,CAAO,EAAI,EAAU,CAAC,CAAO,GAAG,IAAI,MAAM,EAC7F,GAAI,EAAK,SAAW,EAAG,MAAU,MAAM,sDAAsD,EAC7F,MAAO,IAAK,EAAW,EAAI,CAAI,EAAG,QAAS,OAAO,OAAO,CAAI,CAAE,EAK1D,IAAM,EAAM,IAAI",
8
8
  "debugId": "58A4BBBA7E66031D64756E2164756E21",
9
9
  "names": []
10
10
  }
@@ -19,7 +19,13 @@
19
19
  //
20
20
  // fields — a plain object of { name: value } over the trigger root's collected
21
21
  // controls (the SAME snapshot reactive_compute reads — #collectFields).
22
- // Values are the raw control values (strings; a checkbox is a boolean).
22
+ // Values are the raw control values (strings; a lone checkbox is a
23
+ // boolean; a `[]` group is an array of the TICKED values, issue #258 —
24
+ // before that fix such a group arrived as one box's checked state).
25
+ // A group with nothing ticked is PRESENT as an empty array, not
26
+ // missing. The key keeps its suffix, so it is `fields["tags[]"]`,
27
+ // and `[]` is truthy in JS — test `fields["tags[]"].length`, never
28
+ // `if (fields["tags[]"])`.
23
29
  //
24
30
  // It returns truthy to WARN (the confirm dialog fires with the declared message)
25
31
  // or falsy to PROCEED with no dialog. The predicate is soft-validation UX, NOT
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["confirm_predicate.js"],
4
4
  "sourcesContent": [
5
- "// The client-side confirm-predicate registry — the multi-field escape hatch for\n// conditional confirmation (issue #179).\n//\n// confirm: { when: { total: 0 }, message: } handles the single-field 80% case\n// declaratively (the reactive_show conditions language), with NO JS. But some\n// soft-validation is multi-field — \"warn if the end date precedes the start\",\n// \"warn if two related totals disagree\" — which a single field=value condition\n// can't express. This registry is the seam for that logic: name a pure function\n// with `confirm: { predicate: \"name\", message: }` (Ruby) and register it here.\n//\n// The seam mirrors compute.js (setComputeReducer) and confirm.js: a settable\n// registry with a lookup the controller calls. Register once at boot:\n//\n// import { setConfirmPredicate } from \"phlex/reactive/confirm_predicate\"\n// setConfirmPredicate(\"end_before_start\", ({ starts_at, ends_at }) =>\n// ends_at !== \"\" && ends_at < starts_at)\n//\n// The predicate's signature is (fields) => boolean:\n//\n// fields — a plain object of { name: value } over the trigger root's collected\n// controls (the SAME snapshot reactive_compute reads — #collectFields).\n// Values are the raw control values (strings; a checkbox is a boolean).\n//\n// It returns truthy to WARN (the confirm dialog fires with the declared message)\n// or falsy to PROCEED with no dialog. The predicate is soft-validation UX, NOT\n// authorization: a user can bypass it (devtools, an unregistered name) and the\n// action still hits the endpoint's real authorize/default-deny — never let a\n// predicate stand in for a server-side check.\n//\n// A missing predicate (name never registered) makes the gate a NO-OP: the\n// controller proceeds WITHOUT a dialog and warns, exactly like compute.js's\n// unknown-reducer posture — a stale/typo'd name must not break the page or\n// (worse) block a legitimate action behind a dialog that can never resolve.\n\nconst predicates = new Map()\n\n// Register (or replace) the predicate for `key`. `fn` is\n// (fields: Record<string, unknown>) => boolean — truthy warns, falsy proceeds.\nexport function setConfirmPredicate(key, fn) {\n predicates.set(key, fn)\n}\n\n// Look up a registered predicate; undefined when none — the controller then\n// proceeds without a dialog (and warns) rather than throwing or blocking.\nexport function confirmPredicate(key) {\n return predicates.get(key)\n}\n\n// Test seam: clear the registry so a predicate registered in one test can't leak.\nexport function __resetConfirmPredicateRegistryForTest() {\n predicates.clear()\n}\n"
5
+ "// The client-side confirm-predicate registry — the multi-field escape hatch for\n// conditional confirmation (issue #179).\n//\n// confirm: { when: { total: 0 }, message: } handles the single-field 80% case\n// declaratively (the reactive_show conditions language), with NO JS. But some\n// soft-validation is multi-field — \"warn if the end date precedes the start\",\n// \"warn if two related totals disagree\" — which a single field=value condition\n// can't express. This registry is the seam for that logic: name a pure function\n// with `confirm: { predicate: \"name\", message: }` (Ruby) and register it here.\n//\n// The seam mirrors compute.js (setComputeReducer) and confirm.js: a settable\n// registry with a lookup the controller calls. Register once at boot:\n//\n// import { setConfirmPredicate } from \"phlex/reactive/confirm_predicate\"\n// setConfirmPredicate(\"end_before_start\", ({ starts_at, ends_at }) =>\n// ends_at !== \"\" && ends_at < starts_at)\n//\n// The predicate's signature is (fields) => boolean:\n//\n// fields — a plain object of { name: value } over the trigger root's collected\n// controls (the SAME snapshot reactive_compute reads — #collectFields).\n// Values are the raw control values (strings; a lone checkbox is a\n// boolean; a `[]` group is an array of the TICKED values, issue #258 —\n// before that fix such a group arrived as one box's checked state).\n// A group with nothing ticked is PRESENT as an empty array, not\n// missing. The key keeps its suffix, so it is `fields[\"tags[]\"]`,\n// and `[]` is truthy in JS — test `fields[\"tags[]\"].length`, never\n// `if (fields[\"tags[]\"])`.\n//\n// It returns truthy to WARN (the confirm dialog fires with the declared message)\n// or falsy to PROCEED with no dialog. The predicate is soft-validation UX, NOT\n// authorization: a user can bypass it (devtools, an unregistered name) and the\n// action still hits the endpoint's real authorize/default-deny — never let a\n// predicate stand in for a server-side check.\n//\n// A missing predicate (name never registered) makes the gate a NO-OP: the\n// controller proceeds WITHOUT a dialog and warns, exactly like compute.js's\n// unknown-reducer posture — a stale/typo'd name must not break the page or\n// (worse) block a legitimate action behind a dialog that can never resolve.\n\nconst predicates = new Map()\n\n// Register (or replace) the predicate for `key`. `fn` is\n// (fields: Record<string, unknown>) => boolean — truthy warns, falsy proceeds.\nexport function setConfirmPredicate(key, fn) {\n predicates.set(key, fn)\n}\n\n// Look up a registered predicate; undefined when none — the controller then\n// proceeds without a dialog (and warns) rather than throwing or blocking.\nexport function confirmPredicate(key) {\n return predicates.get(key)\n}\n\n// Test seam: clear the registry so a predicate registered in one test can't leak.\nexport function __resetConfirmPredicateRegistryForTest() {\n predicates.clear()\n}\n"
6
6
  ],
7
- "mappings": "AAkCA,IAAM,EAAa,IAAI,IAIhB,SAAS,CAAmB,CAAC,EAAK,EAAI,CAC3C,EAAW,IAAI,EAAK,CAAE,EAKjB,SAAS,CAAgB,CAAC,EAAK,CACpC,OAAO,EAAW,IAAI,CAAG,EAIpB,SAAS,CAAsC,EAAG,CACvD,EAAW,MAAM",
7
+ "mappings": "AAwCA,IAAM,EAAa,IAAI,IAIhB,SAAS,CAAmB,CAAC,EAAK,EAAI,CAC3C,EAAW,IAAI,EAAK,CAAE,EAKjB,SAAS,CAAgB,CAAC,EAAK,CACpC,OAAO,EAAW,IAAI,CAAG,EAIpB,SAAS,CAAsC,EAAG,CACvD,EAAW,MAAM",
8
8
  "debugId": "64DE097A0F67E08564756E2164756E21",
9
9
  "names": []
10
10
  }