ADR 0054: A list shows the choice it will not erase
Status: Accepted
Context
Section titled “Context”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.
Decision
Section titled “Decision”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.
Consequences
Section titled “Consequences”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.
Alternatives rejected
Section titled “Alternatives rejected”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.
Verification
Section titled “Verification”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.
Security and privacy
Section titled “Security and privacy”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.