Skip to content

ADR 0054: A list shows the choice it will not erase

Status: Accepted

options-reconciliation.ts opens by naming its own scope: “Any control that offers a list faces this: a form holds "fr", the options arrive without it, and the control has to show something.” It settles that the widget does not write to the model to make itself consistent, because erasing the value destroys the one thing that would let the user fix it, and it adds the held value to the painted list so the user can see and replace it.

Three findings showed that neither half was applied consistently.

The index collapsed for object values. createSelectController and createMultiselectFieldController both defaulted keyFor to String(option.value), and String({id: 1}) is "[object Object]" — as is every other object. The index held one entry, whichever option was written last:

asked for { id: 1 } -> held { id: 3 }
asked for { id: 2 } -> held { id: 3 }

Not a failure to select: selecting the wrong thing, silently, while staying internally consistent. The same module already reasoned about exactly this — sameChoice says “Never loose between objects: String() renders every plain object as [object Object], so a comparison through it says two different entities are the same one” — and the reasoning simply never reached the place the key is derived.

The survivor was unreadable. It was built as { value, label: String(value) }, so an object value produced a field reading [object Object]. Worse than clearing in one respect: cleared is visibly empty, while that looks like a value and gives nothing to act on.

The survivor had no part. MdySelectState.options documents that a renderer paints that rather than the list it was handed, and both views built their option parts from the declared list. The one entry a user needs in order to replace their value rendered with no id, no role="option" and no aria-selected — an element inside a listbox that is not an option.

And the rule reached two controls out of three: a radio group painted nothing for a value its list did not offer.

An option is keyed by what it holds. The default keyFor keys a primitive exactly as before — keys are consumer-visible, they become part ids and land in aria-activedescendant, so a value that already keys distinctly is untouched — and an array too, since String(["b"]) is "b" and distinct per array. A plain object is keyed structurally, the same rule oneOf uses to recognise an option (ADR 0051), so a list rebuilt from fresh objects by a refetch keys the same where identity would not. defaultOptionKey is exported.

A survivor is named by the option it came from. The controller remembers the label each key was last painted with, so a refetch that drops Ada leaves Ada on screen. A value that was never in any list — from a draft, a patch — falls back to a readable form, never [object Object]. optionsWithUnrecognizedValue(s) takes an optional labelFor for callers that know better.

A view’s parts come from the painted list, in both controllers, which is what their own state contract already said.

A radio group follows the same rule. It has no trigger to show a held value in, so hiding it left an unanswered question that has an answer, submitted unseen. The phantom entry is the price and it is the smaller one: a group with an extra radio is legible and correctable.

A radio group holding a value outside its list now renders an extra option, and state().selectedKey resolves instead of reading null. That reverses a pinned behaviour of that one controller — the test asserting it is replaced rather than deleted, and this record is why.

Two structurally identical objects in one option list share a key, as they share a oneOf verdict. Distinguishing them is what an id is for.

Keying an object walks it rather than stringifying it, once per option per rebuild, capped at eight levels deep — below which two options that differ only there share a key, which fails to distinguish rather than pointing at the wrong row.

Identity keys (a WeakMap) for non-primitives. Stable within one list and unstable across a setOptions that rebuilds it from fresh objects — which is what an API refetch produces — so a selection that survives a refetch today would stop surviving it.

Leave the label to the caller. There is no labelFor on the field controllers’ options and the value can arrive from a draft that no list ever held; a default that produces [object Object] is not a default.

Let the radio group opt out, and pin the difference. Defensible — a group with a phantom option is arguably worse than an empty one — and it loses the argument on its own evidence: the select has a trigger that shows the value and the radio group has nothing, so the control with the weaker case for hiding it was the one hiding it.

  • packages/widgets/test/option-field-controller.spec.mjs — a dropped value keeps its key and gains a part; a value the list offers is not painted twice.
  • battle-tests/adversarial/collections/select-option-identity.battle.test.mjs, unrecognized-option-label.battle.test.mjs, option-controllers-blast.battle.test.mjs — the attacks that found all three, across the three controllers.

None directly. The submitted value is unchanged in every case: what changes is whether the user can see and correct what they are about to submit — and a form that submits a choice its user cannot see is the shape a consent or a permission field must never have.

Amendment, 2026-10-11: an array is keyed by String() only where String() distinguishes

Section titled “Amendment, 2026-10-11: an array is keyed by String() only where String() distinguishes”

The Decision above keeps String() for an array because String(["b"]) is distinct per array. It is not, in general: String() joins entries with commas and renders each its own way, so [{a: 1}] and [{b: 2}] both key as [object Object], [null] and [""] both as nothing, ["a,b"] and ["a", "b"] both as a,b, and [[1, 2]] as [1, 2] does. Those collisions are the index collapse this record was written against, reached through an array.

An array keeps its String() key when every entry is a number, boolean, bigint or a non-empty string holding no , and no [; any other array is keyed structurally, as an object is. A key a consumer already sees for an array of plain entries does not move, and every array whose key moves was sharing it with another.

The alternative that lost is keying every array structurally: one rule instead of two, and it moves the id of every array option that works today (a,b becomes ["a","b"]), which is a visible change paid by consumers who had no defect. Keying a whole list at once, structurally only where two of its options collide, loses too: one option’s key would depend on which others arrive beside it, so a refetch that adds a neighbour would move a selection.

Values whose text is the same can still share a key, as they did before: an array and a primitive (["a"] and "a", [] and ""), and arrays of numbers or booleans and arrays of their text ([1, 2] and ["1", "2"], [true] and ["true"]) — the limit the primitives 1 and "1" have always had. A list mixing those shapes for one field is not a list this rule distinguishes.

Verification adds packages/widgets/test/an-array-keyed-apart-from-its-neighbour.spec.mjs. Security and privacy are unchanged in kind: a collision selected the wrong option silently, so removing one removes a way to submit a choice the user did not make.