Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 139 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]


### Added

- **`bin/release` — the release front door, ported from pgbus.** Works out the
Expand Down Expand Up @@ -441,6 +440,145 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed

- **A checkbox group collapsed to one boolean, and the chosen values never left
the browser (#258).** `#collectFields` wrote `fields[name] = field.checked` for
every checkbox, so several boxes sharing a `features[]` name overwrote each
other and the action received the LAST box's checked state — `{}` under a
`[:string]` schema, `"false"` under a flat `:string` one, silent either way,
while a native submission of the same boxes sends
`features[]=news&features[]=events`. A name ending in `[]` is now collected as
an array of the chosen values: a ticked box contributes its `value`, an
unticked one nothing, a `<select multiple>` its selected options. The suffix is
the only trigger — a group says so rather than being inferred from two controls
sharing a name.

**That makes the suffix a migration point.** ANY `[]`-named control now
contributes to an array, including a single one and including a named rich
editor or bare contenteditable, which the collector reads in a second pass: a
lone `<input type="text" name="tags[]">` used to post `"abc"` and now posts
`["abc"]`. An editor sharing its `[]` name with another control adds its
value to the group instead of standing down behind it, so the entry count
changes there too. It stands down beside a radio that is the only native
control under that name, because a radio keeps its single value with or
without the suffix and nothing has made the name a group; with a second
native control present the name is a group and the editor appends there too.
A ready but empty editor contributes an empty string, the way an empty text
field in the same group does. In an otherwise empty group that matters over a
form body: the group then carries [""] rather than [], so it is not announced
as cleared, exactly as an empty text field in the same group already behaved.
An unticked box still contributes nothing. A hidden input under the same name
keeps contributing: nothing in the DOM tells a hidden that mirrors an editor
from one that is a list JS maintains, and a value posted twice is visible
where a swallowed one is not. Against a flat `params: { tags: :string }` the
array coerces to the literal `"[\"abc\"]"` — silently, with a 200. A control
whose name ends in `[]` has to be declared as an array type (`tags:
[:string]`), or renamed without the suffix if it was never meant as a list.

Three shapes keep their meaning on purpose: a lone checkbox without `[]` stays
the documented yes/no boolean, a radio group keeps its single checked value
with or without the suffix, and a hidden input sharing a name with a checkbox
is that box's companion. The companion is identified by the shared name, not
by its value, because Rails renders three of them: `check_box` emits
`value="0"`, `check_box(..., multiple: true)` the same under a `[]` name, and
`collection_check_boxes` a blank one — or none at all with
`unchecked_value: nil`. Reading the second shape by value collected
`["0","0","0","3"]` for three boxes with the third ticked.

A form body cannot carry an empty array, so a cleared group is ANNOUNCED: its
key stays absent from `params` and its name rides in a field of its own,
`empty_groups[]`, which the endpoint fills with `[]`. The field is additive —
a request without it behaves exactly as before — and values always win over an
announcement. A blank entry (`params[name][]=""`) was the alternative and is
ambiguous: Rails leaves `[""]` to the caller, and the schema reads it per
element type (`[:string]` keeps it, `[:integer]` coerces `[0]`, `[:date]` and
`[:file]` drop the key), so treating it as "cleared" would have changed all
four — for a `[:file]` param backing a `has_many_attached`, the difference
between "the field did not come in" and purging the attachments.

`post_reactive_multipart` takes `empty_groups:` so a request spec can
reproduce a cleared group the way the client sends it; omitted, the body is
exactly what it was before the field existed. `ParamSchema.bracket_path(key)`,
`ParamSchema.row_index?(segment)` and `ParamSchema#declares_array?(path)` are
public for the same resolution — one parser for the wire format, one test for
what counts as a row, and one answer to "does this declaration name an array
here".

An announcement resolves against the DECLARED shape and nothing else. A
string-keyed declaration (`params: { "features" => [:string] }`) fills like a
symbol one — `compile` keeps whichever form the author wrote, so a lookup
that tried only symbols refused half the valid declarations. A group inside
a collection resolves through its row index — `rows_attributes[0][features]`
for nested attributes, `matrix[0]` for an array of arrays — because a
declaration describes its element once while the wire names a row, so the
index has no counterpart to look up. Anything else —
a name the action never declared, a declared param that is not an array,
invented nesting, a row key that is not an index — is dropped rather than
written into the raw params.

Where a row index sits in the announced name decides whether it may be
created. An index ON THE WAY to the group is followed and never created: for
`rows_attributes[0][features]`, bringing the row into being would let the
ANNOUNCEMENT hand the action `rows_attributes: [{ features: [] }]` — a child
record for `accepts_nested_attributes_for` to take at face value — out of a
request that carried nothing else. A row that is really there says so through
its other fields, and `fields_for` renders the hidden id, so following it is
enough. When such an index is refused, the container above it is refused with
it rather than left behind, because an empty collection there reads as "the
caller cleared every row".

The limit of that rule, measured rather than assumed: it constrains what an
announcement may build, not what the endpoint accepts. A JSON body from the
same DOM carries `rows_attributes[0][features][]` as an empty array outright
and does produce `[{ features: [] }]`. So for the one shape where a row
carries NOTHING but an emptied group, the two encodings disagree — the form
body reads as "no rows", the JSON body as "one row with an empty group".
Every shape in which the row carries anything else, which is what `fields_for`
renders, agrees.

An index as the LAST segment is created, because there the row IS the group:
`matrix: [[:string]]` announces a cleared row as `matrix[0]`, and the walk
admits a name only where the declaration names an ARRAY TYPE at that
position, while the endpoint writes `[]` at the leaf either way — so what
appears is an empty array and never a record. Refusing it would leave the last
emptied row of a matrix with no way to say so — the distinction the field
exists to carry. It is also no more than a value can do: `matrix[2][]` posted
beside row 0 produces the same shape, in both encodings.

`reactive_persist` drafts such a group as the list of ticked values and
restores exactly those boxes; before, the draft held one boolean and the
restore ticked every box of the group. Whether the server already rendered a
box ticked — in which case it keeps its say and the draft yields — is decided
once before the restore walks the controls, because the walk writes `checked`
as it goes and asking from inside it would read the restore's own work: the
first box it ticks would make every later box of the group look
server-rendered, and a draft of two values would come back as one. A
non-checkbox control sharing the group's name additionally threw inside the
draft write, which is swallowed — the root then persisted nothing at all,
silently. On restore, such a control keeps what the server rendered whenever
the group has two or more contributors: the list records the values, not
which control each one came from, so replaying it would paste `freeform,news`
into a text field, an editor, or a contenteditable. A group of ONE
contributor has no such ambiguity — its single entry can only have come from
that control — so a plain field whose name merely ends in `[]`, the usual
shape for a list JS maintains, keeps its draft exactly as it did before
groups existed. A `<select multiple>` reads a list by matching option values,
which is only sound when the list is its own: sharing a group with another
contributor, it too keeps what the server rendered, since a text value that
happens to equal an option would otherwise select it.

Drafts written before this release are not discarded, but their group key is
no longer applied to the controls that read a list: it holds one boolean (or,
in a mixed group, whichever control wrote last), and applying that kept
causing damage for as long as the draft lived — by default seven days after
the upgrade. The damage differed by control. A checkbox group came back fully
ticked. A `<select multiple>` sharing the group's name lost its rendered
selection instead, because under `restore: "always"` the select branch skips
the "the server had a say" check and matches `Set{"true"}` against its
options, where nothing matches. The next snapshot replaces the key with the
list. Only that one key changed meaning, which is why `PERSIST_VERSION` stays
where it is: bumping it would also throw away the drafted prose of every form
that has no checkbox group at all.

- **`reply.pending` kept its settle handle under
`enqueue_after_transaction_commit = true` (#254).** The handle was captured in
`Phlex::Reactive::Settles#serialize`, which reads a thread-local that lives
Expand Down
54 changes: 46 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,12 +491,48 @@ def view_template
end
```

> **One multipart caveat:** `FormData` can't carry an *empty* array or hash, so on
> the multipart (file-present) path an empty `[]`/`{}` param is **omitted** and the
> action's keyword default applies — it does **not** arrive as an explicit empty
> collection the way it does over JSON. If you rely on sending `tags: []` to clear
> a collection, send that action *without* a file (the JSON path). A non-empty
> nested/array param rides along fine next to a file.
> **One multipart caveat:** `FormData` can't carry an *empty* array or hash. A
> **checkbox group** (a name ending in `[]`) is covered: a cleared group is
> announced instead — its key stays out of `params` and its name rides in
> `empty_groups[]`, a field of its own beside `token`/`act`/`params`, which the
> endpoint fills with `[]`. So JSON and form bodies agree about a cleared group,
> with one measured exception noted below. The field is additive: a request
> without it behaves exactly as before, and values sent for a group always win
> over an announcement. An announcement only fills what the action DECLARED as
> an array — a group inside a collection resolves through its row index
> (`rows_attributes[0][features]` for nested attributes, `matrix[0]` for an
> array of arrays), a declaration written with string keys resolves like a
> symbol one, and any other name is ignored rather than written into the params.
> A row index on the way to the group is *followed*, never *created* —
> announcing `rows_attributes[0][features]` fills a row the request carried and
> never brings one into being. An index as the LAST segment is created, because
> there the row IS the group (`matrix[0]`), and the declaration has to name an
> array type at that position. The one shape where the two encodings disagree is
> a brand-new nested-attributes row whose only control is the cleared group: it
> carries no id to travel with, so the form body reads as "no rows" where the
> JSON body reads as one row with an empty group. Every **other** empty
> `[]`/`{}` param is still **omitted** on the multipart (file-present) path and
> the action's keyword default applies. If you rely on sending `tags: []` to
> clear a collection through a param that is not a `[]`-named group, send that
> action *without* a file (the JSON path). A non-empty nested/array param rides
> along fine next to a file.

**Checkbox groups.** Several controls sharing a name that ends in `[]` are collected
as an **array of the chosen values** — a ticked box contributes its `value`, an
unticked one nothing, a `<select multiple>` its selected options. Nothing ticked is
an empty array, not a missing key, so an action can tell a cleared group from one
that never rendered. Declare it as an array type:

```ruby
action :save, params: { features: [:string] } # <input type="checkbox" name="features[]" value="news">
```

Three shapes keep their own meaning: a lone checkbox without `[]` stays the
documented yes/no boolean; a radio group keeps its single checked value, `[]` or
not; and a hidden input sharing a name with a checkbox is that box's **companion**
(Rails' `check_box` emits one, carrying the `unchecked_value`) and contributes
nothing. A hidden input *without* a same-named checkbox is an ordinary value — the
usual shape for a list maintained by JS.

**Array & nested params.** Wrap a type in an array for an array param, or a hash
schema in an array for Rails-style nested attributes — so one reactive action can
Expand Down Expand Up @@ -3222,8 +3258,10 @@ endpoint maps it to 403). Matchers: `have_reactive_replace`,
refresh so a reply that would silently break the next click fails your test.

**HTTP helpers** — `post_reactive_action(component_or_class, act, params:, payload:)`
and `post_reactive_multipart(...)` POST a signed token to
`Phlex::Reactive.action_path` exactly as the client does. **Token minting** —
and `post_reactive_multipart(..., empty_groups: [])` POST a signed token to
`Phlex::Reactive.action_path` exactly as the client does; `empty_groups:` names
the `[]` groups the client cleared, which a form body announces rather than
carries. **Token minting** —
`reactive_token_for(component_or_class, payload = {})`.

> `verbose_errors` defaults ON in test (it changes only an error BODY, never a
Expand Down
Loading