Skip to content

Fix selectbox and multiselect clearing selections for custom objects#13648

Merged
lukasmasuch merged 7 commits intodevelopfrom
lukasmasuch/fix-header-height
Jan 22, 2026
Merged

Fix selectbox and multiselect clearing selections for custom objects#13648
lukasmasuch merged 7 commits intodevelopfrom
lukasmasuch/fix-header-height

Conversation

@lukasmasuch
Copy link
Copy Markdown
Collaborator

@lukasmasuch lukasmasuch commented Jan 20, 2026

Describe your changes

Fixes issue #13646 where st.multiselect() and st.selectbox clears user selections after each script rerun when using custom class objects without __eq__ implementation and a format_func parameter. This was a regression introduced in PR #13448.

Root Cause: The validation logic compared multiselect values using ==, which falls back to identity comparison for objects without __eq__. Since register_widget() deepcopies widget values, the new instances fail identity comparison with original options, causing valid selections to be filtered out.

Solution: Changed validation to compare values using format_func() by their formatted string representation instead of using ==. This is more robust and works correctly regardless of whether custom classes implement __eq__.

Testing Plan

  • Unit Tests: Added 4 comprehensive test cases covering custom objects without __eq__, partial matches, objects with __str__, and edge cases where format_func fails on incompatible types
  • E2E Tests: Added test_multiselect_custom_objects_without_eq to verify selections persist across script reruns with custom class objects
  • Backward Compatibility: The format_func parameter has a default value of str, so existing code continues to work without changes

Contribution License Agreement

By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.

…#13646)

When using st.multiselect() with custom class objects and a format_func,
selections would be cleared after each script rerun in v1.53.0. This was
a regression introduced by PR #13448.

The issue occurred because register_widget() deepcopies widget values, and
the validation logic used == comparison which falls back to identity checks
for objects without __eq__. Deepcopied instances fail identity comparison,
causing valid selections to be filtered out.

The fix changes the validation to use format_func() for comparisons instead,
comparing by formatted string representation. This is more robust and works
correctly with custom objects regardless of __eq__ implementation. The
solution also includes error handling for edge cases where format_func
fails on incompatible value types.

- Modified validate_and_sync_multiselect_value_with_options() to accept
  and use format_func parameter
- Added comprehensive unit tests for custom objects with and without __eq__
- Added edge case test for format_func failures
- Added E2E test to verify selections persist across script reruns

Co-Authored-By: Claude <[email protected]>
Copilot AI review requested due to automatic review settings January 20, 2026 15:13
@snyk-io
Copy link
Copy Markdown
Contributor

snyk-io bot commented Jan 20, 2026

Snyk checks have passed. No issues have been found so far.

Status Scanner Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Jan 20, 2026

✅ PR preview is ready!

Name Link
📦 Wheel file https://core-previews.s3-us-west-2.amazonaws.com/pr-13648/streamlit-1.53.0-py3-none-any.whl
📦 @streamlit/component-v2-lib Download from artifacts
🕹️ Preview app pr-13648.streamlit.app (☁️ Deploy here if not accessible)

@lukasmasuch lukasmasuch changed the title Fix multiselect clearing selections for custom objects without __eq__ Fix multiselect clearing selections for custom objects Jan 20, 2026
@lukasmasuch lukasmasuch added security-assessment-completed change:bugfix PR contains bug fix implementation impact:users PR changes affect end users labels Jan 20, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 regression where st.multiselect() would clear user selections after each script rerun when using custom class objects without __eq__ implementation. The root cause was that validation logic used == comparison, which falls back to identity comparison for objects without __eq__, causing deepcopied widget values to fail validation.

Changes:

  • Modified validation logic to compare values using their formatted string representation via format_func instead of direct equality comparison
  • Added comprehensive unit tests covering custom objects without __eq__, partial matches, and edge cases
  • Added E2E test to verify selections persist across script reruns with custom objects

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
lib/streamlit/elements/lib/options_selector_utils.py Updated validate_and_sync_multiselect_value_with_options to compare values using format_func instead of ==, with exception handling for format failures
lib/streamlit/elements/widgets/multiselect.py Passed format_func parameter to validation function
lib/tests/streamlit/elements/lib/options_selector_utils_test.py Added 4 comprehensive unit tests covering custom objects scenarios and edge cases
e2e_playwright/st_multiselect.py Added test app with custom class objects without __eq__
e2e_playwright/st_multiselect_test.py Added E2E test verifying selections persist across reruns

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Jan 20, 2026

📉 Frontend coverage change detected

The frontend unit test (vitest) coverage has decreased by 0.0000%

  • Current PR: 86.4600% (13347 lines, 1807 missed)
  • Latest develop: 86.4600% (13347 lines, 1807 missed)

✅ Coverage change is within normal range.

📊 View detailed coverage comparison

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

# can be formatted successfully.
continue

if formatted_value in formatted_options_set:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Validation and serialization use inconsistent comparison methods

Low Severity

The new validation uses format_func comparison while the existing serialize method uses options.index() (which relies on ==). Since register_widget always deepcopies values, custom objects without __eq__ can pass validation but fail serialization. When serialization fails, the fallback assumes the value is a string (per the comment at line 129-130), but it's actually a custom object. This inconsistency can cause incorrect serialization if widget_state.value_changed is True (e.g., when using st.session_state[key] = value).

Additional Locations (1)

Fix in Cursor Fix in Web

@lukasmasuch lukasmasuch added the ai-review If applied to PR or issue will run AI review workflow label Jan 20, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Jan 20, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR fixes issue #13646 where st.multiselect() was clearing user selections after script reruns when using custom class objects without __eq__ implementation and a format_func parameter. This was a regression introduced in PR #13448.

Root Cause: The validation logic in validate_and_sync_multiselect_value_with_options compared multiselect values using ==, which falls back to identity comparison for objects without __eq__. Since register_widget() deepcopies widget values, the new instances fail identity comparison with original options, causing valid selections to be filtered out.

Solution: Changed the validation to compare values using format_func() by their formatted string representation instead of using == directly.

Code Quality

The code changes are well-structured, minimal, and follow existing patterns in the codebase.

lib/streamlit/elements/lib/options_selector_utils.py

  • The new format_func parameter has a sensible default value (str), maintaining backward compatibility
  • The use of a set for formatted_options_set provides O(1) lookup, which is an improvement over the previous O(n) lookup via index_()
  • The exception handling with except Exception at line 385 is acceptable because:
    • It only affects the specific value being validated (fails safe by filtering out the value)
    • The comment clearly explains the edge case (format_func failing on incompatible types like raw strings from previous sessions)
    • The # noqa: S112 annotation shows awareness of this design choice

lib/streamlit/elements/widgets/multiselect.py

  • Simple one-line change that passes format_func to the validation function - clean integration

Potential Edge Case (not a blocking issue)

If two different options have the same format_func output (e.g., two objects where format_func returns "same_label"), both values would be considered valid. This is consistent with the existing multiselect behavior where duplicate formatted options are allowed, so this is not a regression.

Test Coverage

Excellent test coverage. The changes are thoroughly tested with both unit tests and E2E tests.

Unit Tests (lib/tests/streamlit/elements/lib/options_selector_utils_test.py)

Four comprehensive test cases added in TestValidateMultiselectWithCustomObjects:

  1. test_custom_objects_without_eq_using_format_func - Tests the primary fix scenario
  2. test_custom_objects_partial_match_with_format_func - Tests filtering when some options are removed
  3. test_default_format_func_with_custom_str - Tests objects with __str__ using default format_func
  4. test_format_func_failure_filters_out_value - Tests edge case where format_func fails on incompatible types

All tests include:

  • Docstrings explaining the purpose (following best practices)
  • Both positive assertions (values kept) and negative/boundary assertions (values filtered, needs_reset flag)
  • Clear comments explaining the test scenario

E2E Tests (e2e_playwright/st_multiselect_test.py)

The new test_multiselect_custom_objects_without_eq test follows best practices:

  • Uses expect for auto-wait assertions (not assert)
  • Uses label-based locators via get_multiselect() helper
  • Has descriptive name and docstring with issue reference
  • Verifies initial state before interaction
  • Tests single selection, then multiple selections
  • Includes both value verification (expect_text) and UI verification (tag count)

The test app changes in e2e_playwright/st_multiselect.py properly:

  • Define the CustomOption class without __eq__ (with appropriate # noqa: B903)
  • Create new option instances on each script run (simulating the bug trigger)
  • Use format_func to display labels

Backwards Compatibility

Fully backward compatible. The format_func parameter has a default value of str, so:

  • Existing code continues to work without changes
  • The default behavior for simple types (strings, numbers) remains the same
  • Only affects multiselect widgets that use custom objects without __eq__

Security & Risk

Low risk. This is a targeted bug fix with:

  • No changes to user-facing APIs
  • No changes to data handling or serialization
  • The exception handling is fail-safe (invalid values are filtered out, not propagated)
  • The # noqa: S112 annotation on the broad exception is appropriate for this validation use case

Recommendations

No blocking issues. Minor suggestions for consideration:

  1. Documentation consideration (optional): The PR description is excellent and explains the fix well. Consider adding a note about this fix in the changelog if Streamlit maintains one for bug fixes.

  2. The MULTISELECT_COUNT update: The count was correctly updated from 20 to 21 to reflect the new test widget.

Verdict

APPROVED: This is a well-implemented bug fix that properly addresses the regression. The code changes are minimal and targeted, the test coverage is comprehensive (4 unit tests + 1 E2E test), and the fix is fully backward compatible. The PR follows Streamlit's coding standards and testing best practices.


This is an automated AI review. Please verify the feedback and use your judgment.

Address PR review comment: The validation and serialization methods were
using inconsistent comparison approaches. Validation used format_func
while serialization used options.index() which relies on ==. This could
cause incorrect serialization for custom objects without __eq__ when
widget_state.value_changed is True.

Changes:
- Updated MultiSelectSerde to accept and store format_func parameter
- Modified serialize() to use format_func for finding formatted options
  instead of options.index() which relies on == comparison
- Added test for serializing deepcopied custom objects
- Updated existing test to pass format_func to serde

Co-Authored-By: Claude <[email protected]>
lukasmasuch and others added 2 commits January 20, 2026 20:19
…bjects

This extends the fix from #13646 to selectbox as well. The same issue
existed where custom objects without __eq__ would fail validation and
serialization after deepcopy because the old implementation used
index_() which relies on == comparison.

Changes:
- Update SelectboxSerde to use format_func for serialization
- Update validate_and_sync_value_with_options to use format_func
- Pass format_func from selectbox widget to serde and validation
- Add tests for selectbox with deepcopied custom objects

Co-Authored-By: Claude <[email protected]>
For Enum values, use the original index_() approach which uses ==
comparison. This correctly handles enum class identity - enums from
different classes (e.g., after script rerun) should NOT be considered
equal, which is important for enum coercion to work correctly when
coercion is disabled.

For non-Enum values, use format_func comparison. This handles custom
objects without __eq__ where widget values are deepcopied and the
deepcopied instances would fail identity comparison with ==.

Co-Authored-By: Claude <[email protected]>
@lukasmasuch lukasmasuch changed the title Fix multiselect clearing selections for custom objects Fix selectbox and multiselect clearing selections for custom objects Jan 21, 2026
Copy link
Copy Markdown
Collaborator

@kmcgrady kmcgrady left a comment

Choose a reason for hiding this comment

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

Code changes here are fine. Do we need to consider this for other options-based widgets? If not, can we just add to the PR description, why it's unnecessary?

  • st.radio
  • st.pills
  • st.segmented_control
  • st.select_slider
  • Maybe some column config like SelectboxColumn?

@lukasmasuch
Copy link
Copy Markdown
Collaborator Author

lukasmasuch commented Jan 22, 2026

Code changes here are fine. Do we need to consider this for other options-based widgets?

st.radio -> already addressed in a recent PR
st.pills, st.segmented_control, st.select_slider are not impacted since they are still index-based for widget state, but are planned to be changed to support dynamic options support + potentially also required for binding query params.
Maybe some column config like SelectboxColumn? -> column types are not impacted since they work differently regarding the widget state

@lukasmasuch lukasmasuch merged commit 8150720 into develop Jan 22, 2026
44 checks passed
@lukasmasuch lukasmasuch deleted the lukasmasuch/fix-header-height branch January 22, 2026 18:32
github-actions bot pushed a commit that referenced this pull request Jan 22, 2026
…13648)

## Describe your changes

Fixes issue #13646 where `st.multiselect()` and `st.selectbox` clears
user selections after each script rerun when using custom class objects
without `__eq__` implementation and a `format_func` parameter. This was
a regression introduced in PR #13448.

**Root Cause**: The validation logic compared multiselect values using
`==`, which falls back to identity comparison for objects without
`__eq__`. Since `register_widget()` deepcopies widget values, the new
instances fail identity comparison with original options, causing valid
selections to be filtered out.

**Solution**: Changed validation to compare values using `format_func()`
by their formatted string representation instead of using `==`. This is
more robust and works correctly regardless of whether custom classes
implement `__eq__`.

## Testing Plan

- Unit Tests: Added 4 comprehensive test cases covering custom objects
without `__eq__`, partial matches, objects with `__str__`, and edge
cases where `format_func` fails on incompatible types
- E2E Tests: Added `test_multiselect_custom_objects_without_eq` to
verify selections persist across script reruns with custom class objects
- Backward Compatibility: The `format_func` parameter has a default
value of `str`, so existing code continues to work without changes

---

**Contribution License Agreement**

By submitting this pull request you agree that all contributions to this
project are made under the Apache 2.0 license.

---------

Co-authored-by: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:bugfix PR contains bug fix implementation impact:users PR changes affect end users

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants