Skip to content

fix: Coerce non-string selected in checkbox/radio update functions - #2420

Open
jat255 wants to merge 14 commits into
mainfrom
fix/2272-checkbox-int-keys
Open

fix: Coerce non-string selected in checkbox/radio update functions#2420
jat255 wants to merge 14 commits into
mainfrom
fix/2272-checkbox-int-keys

Conversation

@jat255

@jat255 jat255 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #2272

Problem

ui.input_checkbox_group() and ui.input_radio_buttons() accept non-string choice values, such as the int keys of a dict[int, str]. The update_*() functions did not.

Choice values become HTML value attributes, so the client always sees them as strings. The constructors compared the raw choice values against the raw selected, so both sides agreed. _update_choice_input() sent selected without change, so the client received {"value": [0, 1, 2]} instead of {"value": ["0", "1", "2"]}.

This is not a quiet mismatch. CheckboxGroupInputBinding.setValue clears every box, then calls $escape() on each value. $escape() calls .replace(), which throws a TypeError on a number. This is why the reporter saw no boxes checked.

update_radio_buttons() and update_select() had the same cause and a different result. Both throw before they change anything, so a label update in the same message is lost.

Fix

The five choice inputs (checkbox group, radio buttons, select, selectize, toolbar select) now share one normalization module, shiny/ui/_choices.py. Each entry point normalizes choices into a canonical dict keyed by the string form, then resolves selected against that dict's keys once. Everything downstream reads those resolved values, including the rendered options, the selected attributes, and the value in the update_*() payload. This ensures the markup and the payload cannot disagree, because neither re-derives the value.

This decision differs from a more simple fix at the wire boundary, which is the approach that gave rise to the issue in _update_choice_input() raised in #2272. The constructors, the select inputs, and the toolbar each carried their own comparison, leading to possible inconsistencies. Normalizing at the entry point closes the entire class of bug, but has a larger surface area as a result.

This PR also widens the type annotations, so the documented calls pass a type checker. A choice value becomes ChoiceValue = str | int | float | bool | None. The key positions of a choice mapping stay loosely typed as ChoiceKey = Any. (This is because a Mapping type's key parameter is invariant, so a checker demands an exact match there rather than a subtype. Mapping[ChoiceValue, str] therefore accepts only a literal dict[ChoiceValue, str], and rejects dict[int, str] and dict[str, str] alike, even though those are valid transitively. The "proper" fix for this would likely be to add more extensive bounded TypeVars, though that would expand the scope even more).

update_radio_buttons() collapses a list or tuple selected to a single value, because the binding throws on a non-empty array. An empty selected stays an empty list. That is the documented way to clear a selection, and the one array shape the binding accepts.

A separate bug, found while testing this PR

ui.update_radio_buttons(choices=[]) raised IndexError: list index out of range instead of clearing the choices. The shared note on the update_* functions documents choices=[] as the way to clear the set of choices. That text dates from #42, in February 2022, so the call has never worked for radio buttons. ui.update_checkbox_group() and ui.update_select() both honor it and worked fine.

(see this note in shiny/ui/_input_update.py, on main):
image

The radio group failed because an empty group has no first option to fall back on when selected is omitted. It now sends the same payload as ui.update_checkbox_group() for the same call. The constructors still reject an empty choices with no selected.

No open issue covers this. #2400 is adjacent but different: it asks to clear the selection rather than the choices, and selected=[] already does that.

Behavior changes (please double check these are acceptable!)

Some of these changes alter how existing apps might behave, so they're work calling out explicitly:

  • None and bool choice values now render value="None", value="False", and value="True". htmltools dropped the attribute before, or wrote value="" for True, so the browser reported the default "on". The fix requires this, because the client matches on the value attribute and an absent attribute leaves nothing to match. Bookmarks that hold the old values now read the new strings.
    • This manifests as follows: if you have a checkbox group like
      ui.input_checkbox_group(
          "checkbox_group", 
          "Choices:",
          {None: "None key", False: "False key", True: "True key"}
      )
      
      And then a button that runs ui.update_checkbox_group("checkbox_group", selected=[True, False]), you'll get this on current main:
      image
      vs. this after this PR:
      image
      I believe this is more correct.
  • The constructors accept mixed types now, for example choices={0: "a"} with selected=["0"]. This follows from the string coercion that we're doing. It is perhaps less formally correct, though.
    • For example, on main, this radio would render unselected because 1 != "1":
      image
    • After this PR, as a consequence of the string coercion, this would now select the "b" radio box:
      image
  • Choice values that collide once stringified, such as {0: "zero", "0": "oh"}, now raise a ValueError. Coercion to str turns two distinct values into one key, so without this error one option disappears with no message.
    • On main, the server would not be aware that these were different. After this PR, there's an explicit error raised:
      image
  • ui.input_radio_buttons(choices=[]) raises a ValueError that names choices, instead of an IndexError from a private helper. This applies to the constructors only. The update_*() path now clears the group instead, as described above. The fix does not require this error. It is a deliberate addition, because an empty filter result is a realistic way to reach that line.
    • On main, this ended up looking like the following. If you created input_radio_buttons(choices=[]) with an empty choice list, you'd get IndexError: list index out of range raised in _generate_options()#L328. After this PR, you'll get ValueError: choicescannot be empty for a radio button group unlessselected is given. from here. Likewise, calling update_radio_buttons(choices=[]) will actually clear the options for the radio list, rather than the mysterious IndexError.

Judgement-call decisions

This PR's implementation now resolves the values in selected against the actual choice values. i.e. choices={1: "a"} with selected=[1.0] both renders and sends "1". A comparison on raw equality is not correct here. The old code gave the right answer for 1.0 and the wrong one for "1", and it compared values the client never sees: the option carries value="1", so a selected of 1.0 renders as checked but goes onto the wire as a 1.0 that no option carries.

An update_*() message has two halves: the regenerated options markup and the value. Both now come from a single coercion, so they cannot disagree. A fix that coerces only the value leaves the markup with nothing checked for a mixed-type call, such as choices={0: "a"} with selected=["0"]. The client then re-checks the box from the value, which hides the mismatch instead of removing it.

Prior art

#2362 diagnosed this problem first and is now closed. Credit to @eeshsaxena. That PR changed _update_choice_input() only, which covers update_checkbox_group() and update_radio_buttons(). This PR also fixes update_select(), update_selectize(), and toolbar_input_select(), and it changes the input constructors. #2362 branched from #2358 rather than main, so it carried unrelated data-frame commits.

Out of scope

The issue also asks for a bool selected value that checks every box. That is a separate feature request, that I think would be a departure from existing API patterns and this PR does not implement it.

Testing

tests/pytest/test_input_check_radio.py covers the wire payload, the rendered markup, the new errors, and the select inputs. The stub session runs each payload through json.dumps(), so the tests cover the wire format and not only the dictionary.

1087 unit tests pass locally. uv run make format check-lint check-types check-pyright is clean, which covers both pyrefly and pyright. The Playwright input and toolbar tests pass (85 tests). Pyright also reports no errors on a file that holds the documented calls.

@jat255
jat255 force-pushed the fix/2272-checkbox-int-keys branch from 0f6f5d6 to a36965f Compare August 12, 2026 19:46
jat255 added a commit that referenced this pull request Aug 12, 2026
Addresses review feedback on #2420. The original fix coerced `selected` in
the checkbox/radio update path; this moves the normalization into a shared
`shiny/ui/_choices.py` used by the checkbox group, radio buttons, select,
selectize, and toolbar select, and fixes the edges the narrower fix left
open or introduced:

- Widen `ChoicesArg`/`selected` annotations so the documented non-string
  usage type-checks. `Mapping`'s key type is invariant, so the mapping arm
  has to be keyed on `Any` -- a narrower union rejects even `dict[str, str]`.
- Collapse a sequence `selected` to a scalar for `update_radio_buttons()`,
  which otherwise throws client-side. `selected=[]` still clears, since an
  empty array is the one shape that binding special-cases.
- Accept any iterable `selected` (`dict_keys`, `set`, generators) instead of
  stringifying it to an unusable `repr`.
- Raise on choice values that collide once stringified, rather than silently
  dropping an option, and raise a `ValueError` naming `choices` for an empty
  radio group instead of an `IndexError`.
- Resolve `selected` against the actual choice values, so a numerically equal
  entry (`1.0` vs. `1`) both renders and transmits as the value the option
  carries. Matching on raw equality instead would restore the server/client
  disagreement this change exists to remove.
- Fix `update_select()`/`update_selectize()`, which had the same defect, and
  drop the ad-hoc coercion in `_toolbar.py`.
- Correct the comment claiming the coercion is a no-op for rendered HTML
  (`None`/`bool` values change) and the docstring claiming tuples are not
  JSON-serializable (they are).
@jat255
jat255 force-pushed the fix/2272-checkbox-int-keys branch 3 times, most recently from ec580a6 to 02604e3 Compare August 15, 2026 02:56
jat255 added 9 commits August 14, 2026 20:57
`ui.input_checkbox_group()` and `ui.input_radio_buttons()` accept `choices`
dictionaries with non-string keys (e.g. `dict[int, str]`) and non-string
`selected` values, because both end up in HTML `value` attributes, where
htmltools stringifies them.

`ui.update_checkbox_group()` and `ui.update_radio_buttons()` did not: they
forwarded `selected` verbatim as the message's `value`, so the client received
`[0, 1, 2]` instead of `["0", "1", "2"]`. The checkbox/radio input bindings pass
that value through `$escape()`, which calls `String.prototype.replace()` on it
and throws on a number -- after `setValue()` has already cleared every checkbox
-- so all options silently ended up unselected.

Normalize in the shared pipeline instead of at the edge: `_normalize_choices()`
now coerces choice keys to `str`, a new `_normalize_selected()` coerces
`selected` the same way (preserving scalar vs. sequence shape so radio inputs
still get a scalar `value`), and `_update_choice_input()` sends the normalized
value. Both `_generate_options()` callers -- the input constructors and the
update functions -- now compare and emit the same string form.

Fixes #2272
Addresses review feedback on #2420. The original fix coerced `selected` in
the checkbox/radio update path; this moves the normalization into a shared
`shiny/ui/_choices.py` used by the checkbox group, radio buttons, select,
selectize, and toolbar select, and fixes the edges the narrower fix left
open or introduced:

- Widen `ChoicesArg`/`selected` annotations so the documented non-string
  usage type-checks. `Mapping`'s key type is invariant, so the mapping arm
  has to be keyed on `Any` -- a narrower union rejects even `dict[str, str]`.
- Collapse a sequence `selected` to a scalar for `update_radio_buttons()`,
  which otherwise throws client-side. `selected=[]` still clears, since an
  empty array is the one shape that binding special-cases.
- Accept any iterable `selected` (`dict_keys`, `set`, generators) instead of
  stringifying it to an unusable `repr`.
- Raise on choice values that collide once stringified, rather than silently
  dropping an option, and raise a `ValueError` naming `choices` for an empty
  radio group instead of an `IndexError`.
- Resolve `selected` against the actual choice values, so a numerically equal
  entry (`1.0` vs. `1`) both renders and transmits as the value the option
  carries. Matching on raw equality instead would restore the server/client
  disagreement this change exists to remove.
- Fix `update_select()`/`update_selectize()`, which had the same defect, and
  drop the ad-hoc coercion in `_toolbar.py`.
- Correct the comment claiming the coercion is a no-op for rendered HTML
  (`None`/`bool` values change) and the docstring claiming tuples are not
  JSON-serializable (they are).
`ChoiceValue` is `Any`, so narrowing with `isinstance(x, Iterable)` leaves the
element type unknown, which pyright's strict mode rejects. Restate it as
`Iterable[Any]` at the three iteration sites. The two `cast()` calls in
`_input_select.py` were no-ops for the same reason and are removed. Typing the
test helper to the stub session drops a `pyright: ignore`.

`make check-types` runs pyrefly only; the pyright job is separate in CI.
The shared note on the `update_*` functions has documented `choices=[]` as the
way to clear the set of choices since the docstrings were written in #42
(Feb 2022). `update_checkbox_group()` and `update_select()` honor it.
`update_radio_buttons()` raised `IndexError: list index out of range`, because
an empty group has no first option to fall back on when `selected` is omitted.

Found while building a demo for this PR's empty-`choices` behavior change. The
update path now says "nothing is selected" explicitly, so an empty group
renders instead of being rejected, and the payload matches what
`update_checkbox_group()` sends for the same call. The constructors still
reject empty `choices` with no `selected`, where it is a mistake rather than a
request.
Drop the internal mechanics (how the payload was built, which client binding
threw, what htmltools does with a `None` attribute) and keep what an app
author observes and has to act on. Also drop a stale `#2413` reference that
resolves to nothing, and correct the collision entry: both options rendered
with the same value rather than one being dropped.
Collapsing any sequence to its first element had no precedent. Shiny for R's
`updateInputOptions()` applies `as.character()` and passes the whole vector
through, and py-shiny's own `input_select(multiple=False)` marks every listed
value as selected rather than picking one. Silently keeping the first value is
also the opposite of what this PR does for colliding choice values, where it
raises rather than drop one.

A one-element sequence still unwraps, which is where the R comparison actually
holds: `as.character()` on a length-1 vector reaches the client as a JSON
scalar. That covers feeding one input's tuple value into a radio update. More
than one value now raises a `ValueError` naming the parameter, since a radio
group can only ever show one of them.
`choices=[0, "0"]` raises the same error as the dict form and avoids the
key-versus-value question: in a list, the entries are the choice values.
@jat255
jat255 force-pushed the fix/2272-checkbox-int-keys branch from 02604e3 to 22c30c5 Compare August 15, 2026 02:57
@jat255
jat255 marked this pull request as ready for review August 15, 2026 03:02
@jat255
jat255 requested a review from cpsievert August 15, 2026 03:02
@cpsievert
cpsievert requested a lite review from Copilot August 18, 2026 16:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a long-standing mismatch between server-side selected values and the client-side string-only value attributes for Shiny “choice inputs” (checkbox groups, radio buttons, select/selectize, and toolbar select). It centralizes choice/selection normalization so constructors and update_*() messages agree by construction, and adds unit tests to pin both rendered markup and wire payloads.

Changes:

  • Introduces shiny/ui/_choices.py to normalize choice values to strings and to normalize/shape selected consistently across inputs.
  • Updates checkbox/radio/select/selectize/toolbar select constructors and update functions to use shared normalization and handle empty radio choices on update.
  • Adds a focused pytest suite covering rendered HTML, message payloads, error cases, and select inputs.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
shiny/ui/_choices.py New shared normalization utilities for choice values and selected payload shaping.
shiny/ui/_input_check_radio.py Uses shared normalization for checkbox/radio constructors; improves empty-radio error.
shiny/ui/_input_select.py Uses shared normalization for select/selectize constructors and option rendering.
shiny/ui/_input_update.py Updates update_*() functions to resolve/coerce selected consistently and to support clearing radio choices.
shiny/ui/_toolbar.py Applies shared normalization for toolbar select constructor and updater.
tests/pytest/test_input_check_radio.py New unit tests covering HTML + wire payload normalization behaviors and regressions.
CHANGELOG.md Documents behavior changes and fixes related to choice-value coercion and updates.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread shiny/ui/_choices.py Outdated
Comment thread shiny/ui/_input_select.py
jat255 added 2 commits August 18, 2026 11:24
…_values

Addresses review feedback: the fast path returned the selected entry
unchanged when its string form matched a choice, which contradicted the
docstring. Behavior is identical downstream (all consumers stringify),
but returning the canonical value is consistent and handles objects
whose __str__ collides with a key without comparing equal to it.

@cpsievert cpsievert left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, this is a strong direction. Centralizing normalization keeps rendered options and update payloads consistent and closely follows R Shiny's character-coercion model. The radio handling and markup/payload tests are also solid. My inline questions are about defining edge cases, not the overall architecture.

Comment thread shiny/ui/_choices.py Outdated
Comment thread shiny/ui/_choices.py Outdated
Comment thread shiny/ui/_input_select.py Outdated
# `{"a": "A", "Group B": {...}}`). That matches neither arm of the
# `_SelectChoices` union, hence the `cast`, but `_render_choices()`
# checks each value with `isinstance`, so the mix is fine at runtime.
normalized = normalize_choices_mapping(x)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears to apply choice-value collision validation to every top-level key. When the associated value is a mapping, however, the key becomes an optgroup label rather than an option value. For example, {0: {1: "one"}, "0": "flat"} raises even though one "0" is an optgroup label and the other is an option value.

R Shiny treats optgroup names as labels and only the nested leaves as option values, so a group label and option value may have the same string. Are optgroup labels intended to participate in py-shiny's choice-value collision policy?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error enforces uniqueness of the keys of choices after stringification, and those keys are a mix of choice values and optgroup labels.

A select input's normalized choices is one dict keyed by the string form, and that dict holds optgroup labels next to flat choice values. A top-level collision drops an entry either way, so raising an error in the collision is the alternative to losing an option with no message.

You are right about R. choicesWithNames() returns a named list, which holds duplicates, so R renders both entries and needs no check.

I kept the error and made the message much more explicit (dbb226e):

Duplicate key '0' in `choices`: the optgroup label 0 and the choice value '0'
are distinct but are identical as strings. A select input's `choices` (values
and optgroup labels) must be unique when converted to strings.

Only the top level is affected. Each group's contents go through the same function, and those keys are all choice values, so {"G": {0: "a", "0": "b"}} still reports a duplicate choice value.

Comment thread shiny/ui/_input_select.py Outdated
index: dict[str, Any] = {}
for key, value in x.items():
if isinstance(value, Mapping):
index.update(normalize_choices_indexed(value)[1])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This silently replaces an earlier indexed value when two optgroups contain values with the same string form. For example, {"A": {0: "zero"}, "B": {"0": "oh"}} currently renders two value="0" options, and selected=0 marks both with a selected attribute. In a single-select control, the browser ultimately selects the latter option. This differs from the new behavior within one mapping, where a stringification collision raises.

R Shiny permits duplicate underlying values, both within and across groups, although those values are inherently indistinguishable when reported by the browser. Is py-shiny intentionally adopting a stricter policy, and if so, is that policy meant to apply within each group or across the complete input?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method was removed in 4436a04 in the switch to string comparison. Its replacement, _choice_value_strings(), returns a set[str], so nothing is overwritten across groups.


More broadly...

Personally, I think silently dropping things because of the browser's limitations is odd behavior, and my expectation as a Python user would lean towards "this shouldn't be allowed, raise a loud error" rather than implicitly doing something I then have to notice. That is why I went the direction of raising. (I also appreciate this is a deviation from previous practice on the R side, so if you disagree, I'm happy to move to an approach more in line with R)

Why there is a uniqueness rule at all

This is a consequence of where the string coercion happens. One of the key changes of this PR is that normalization happens once in shiny/ui/_choices.py. main keys the choices dict by the raw choice value and coerces to a string later at each point of use. This PR re-keys that dict by the string form once, so every consumer (_render_choices(), ChoiceSelection, _choice_value_strings(), _find_first_option(), the payload builders) reads an already-coerced form, making the rendered markup and the message value agree by construction.

The alternative is to keep the dict keyed by raw values, as main does, and coerce at the two boundaries instead: value=str(k) when rendering, and str(v) in the payload. Both still derive from the same str(), so markup and payload agree, duplicates render as they do in R, and no uniqueness rule is needed.

Some specifics on the duplicate checking

The duplicate check runs on each dict in choices on its own, comparing only that dict's own keys: the outer dict once, then each optgroup's dict. That's where a collision loses a choice, because each dict is re-keyed to a new dict keyed by the string form. Values in two different optgroups are never siblings of one dict, so nothing is dropped and both options render.

Some examples, for clarity:

choices value Behavior on current main after this PR
{0: "zero", "0": "oh"} two value="0" options ValueError (duplicate choice value)
[0, "0"] two value="0" options ValueError (duplicate choice value)
{"G": {0: "a", "0": "b"}} two value="0" options in one group ValueError (duplicate choice value)
{0: {1: "one"}, "0": "flat"} one optgroup + one option, both keyed "0" ValueError (label vs value)
{0: {1: "one"}, "0": {2: "two"}} two <optgroup label="0"> ValueError (label vs label)
{"A": {0: "zero"}, "B": {"0": "oh"}} both render, selected=0 marks the first both render, selected=0 marks both
{"A": {1: "one"}, "B": {1: "uno"}} both render, selected=1 marks both unchanged
[0, False] one option, value="0", label False unchanged

As I understand it, R Shiny permits duplicates in all of the above. So this PR is stricter than R most of the time, and closer to R in the two cross-group rows. The last row diverges from R in both columns, which is covered below.

Marking both options is mostly pre-existing. main already does it when two groups share a value at the same type, as in {"A": {1: "one"}, "B": {1: "uno"}}. Only mixed types change, and there main picked by raw type while both options render value="0" either way.

Values that collide as dict keys still merge silently

choices=[0, False] names two choices, but renders one option: value="0" with the label False, and no error. [1, True] and [1, 1.0] behave the same way. In each case one choice disappears, and the option that survives takes its label from the entry that was dropped. This is because a list is turned into {k: k for k in x}, keyed by the raw value, and 0 and False are the same dict key in Python. Their string forms "0" and "False" are different, so keying by the string form instead keeps both options. That needs no new error, but I can see the argument for making it raise one just to be consistent, which we can fold into this work if wanted.

The same collision in a dict cannot be fixed. {0: "zero", False: "oh"} arrives here as {0: "oh"}, because Python merges the keys before the function is ever called, so there's nothing we can do about it at this point.

Comment thread shiny/ui/_choices.py Outdated
jat255 added 3 commits August 19, 2026 11:27
Addresses @cpsievert's review on #2420.

`resolve_selected()` (was `resolve_selected_values()`) now matches on the
string form only, since that is all the client ever sees. The general `==`
fallback it replaces made `selected=True` name a choice value of `1`, and
could raise or return a non-`bool` for array-like and custom types. An
integral `float` still matches the integer it names, so `selected=1.0`
against `choices={1: "a"}` keeps working, as it does in R Shiny where
`as.character(1.0)` is `"1"`.

Resolution now yields the choice's string form rather than the choice value
as the caller wrote it, so the string-to-original index is gone
(`normalize_choices_indexed()`, `_choice_value_index()`). Every consumer
stringified the result anyway. Dropping it also removes the silent
last-one-wins overwrite when two optgroups held the same choice value; the
markup for that case is unchanged and matches R Shiny, which permits
duplicate values.

`ChoiceValue` is now `str | int | float | bool | None` instead of `Any`, so
the public annotations no longer accept an arbitrary object. Sequence arms
use `Sequence[ChoiceValue]` (invariance rules out `list[ChoiceValue]`, which
would reject `list[str]`), and `Mapping` keys stay `Any` because an exact key
type rejects `dict[int, str]`. A bare `str` satisfies `Sequence[str]`, so
`choices` that is a string, or is not iterable, now raises a clear `TypeError`
instead of an `AttributeError` from a private helper.

A select input's top-level `choices` maps optgroup labels alongside choice
values, so its duplicate-key error now names both. The check has to stay: the
normalized form is a `dict` keyed by the string, so a collision there would
drop an entry with no diagnostic.

Also drops the redundant underscore prefixes inside the private `_choices`
module (`_V`, `_as_raw_list`), and removes `_contains_html()`, dead since
#2049 removed the deprecation warning that was its only caller.
The `keys_are` parameter took the noun that the error message uses, so the
switch value carried the message wording. Only two cases exist, so the caller
now passes `keys_can_be_optgroup_labels` and one helper builds the message.
Both errors are unchanged.
`normalize_choices_mapping()` needed a flag to say whether a key could be an
optgroup label, which put the calling input's shape in the callee's signature.
The labels already carry that fact: a key whose label is a `Mapping` heads an
optgroup, and only a select input's top-level `choices` holds one. So
`duplicate_key_error()` now takes both colliding `(key, label)` pairs and names
each side, and the flag is gone.

The wording follows the data rather than the caller, which the nested case
needs: a select input normalizes each optgroup's contents through the same
function, and those keys are all choice values.
@jat255
jat255 requested a review from cpsievert August 31, 2026 22:41
@jat255

jat255 commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

@cpsievert thanks for your comments and sorry this got interrupted for a while. It should be ready for re-review.

The main changes since your review:

  • resolve_selected() matches selected to a choice by its string form only, so the general == fallback is gone.
  • ChoiceValue is now str | int | float | bool | None instead of Any
  • the string-to-original index pattern is removed (which also removes the cross-optgroup overwrite)
  • the underscore-prefix and _contains_html() points are fixed.

I left two threads unresolved above about the optgroup collision-policy questions, in case you want to take a look. I'm comfortable with where things are at now, but happy to make any changes due to the slight divergence from R shiny behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: update_checkbox_group requires different selected than input_checkbox_group

3 participants