Skip to content

Conversation

@lukasmasuch
Copy link
Collaborator

@lukasmasuch lukasmasuch commented Dec 16, 2025

Describe your changes

Allow dynamically changing the options for st.selectbox without triggering an identity change / state reset. If the current selected options isn't in the list of available option, it will be reset to the default value.

GitHub Issue Link (if applicable)

Testing Plan

  • Added unit and e2e tests.

Contribution License Agreement

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

Copilot AI review requested due to automatic review settings December 16, 2025 10:31
@snyk-io
Copy link
Contributor

snyk-io bot commented Dec 16, 2025

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
Contributor

github-actions bot commented Dec 16, 2025

✅ PR preview is ready!

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

Copy link
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 modifies the selectbox widget to allow dynamic option changes while preserving widget state when a key is provided. The key change is removing "options" from key_as_main_identity, making the widget ID stable across option changes, and adding validation logic to handle cases where the selected value is no longer in the updated options.

Key Changes

  • Widget ID computation now excludes "options" parameter, maintaining stable IDs when options change dynamically with a key
  • Added validation logic to detect and handle cases where the previously selected value is removed from options
  • Updated tests to reflect the new behavior and added integration tests for dynamic option scenarios

Reviewed changes

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

File Description
lib/streamlit/elements/widgets/selectbox.py Modified key_as_main_identity to only include accept_new_options, added validation logic to reset values when selected option is removed from new options list, and updated return value handling
lib/tests/streamlit/elements/selectbox_test.py Added test for accept_new_options ID behavior, renamed and modified test to verify stable IDs with dynamic options/format_func, and added two integration tests for option expansion and shrinking scenarios

@lukasmasuch lukasmasuch added security-assessment-completed Security assessment has been completed for PR change:feature PR contains new feature or enhancement implementation impact:users PR changes affect end users labels Dec 16, 2025
@lukasmasuch
Copy link
Collaborator Author

@cursor review

@github-actions
Copy link
Contributor

github-actions bot commented Dec 16, 2025

📉 Frontend coverage change detected

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

  • Current PR: 86.3400% (12611 lines, 1722 missed)
  • Latest develop: 86.3400% (12611 lines, 1722 missed)

✅ Coverage change is within normal range.

📊 View detailed coverage comparison

@github-actions
Copy link
Contributor

Summary

This PR changes st.selectbox behavior when a key is provided so that the widget ID stays stable even when options change, enabling dynamic updates without losing widget state. It also adds backend-side validation to reset the selection to the default when the previously selected value is no longer present in the new options (when accept_new_options=False). Tests were updated/added in both Python unit tests and Playwright e2e tests, including new snapshot updates.

Code Quality

  • Widget identity change looks intentional and scoped: In lib/streamlit/elements/widgets/selectbox.py, the element ID computation for keyed widgets now only whitelists accept_new_options (and no longer options). This matches the goal of allowing dynamic option changes without forcing a new widget identity.

    • Reference: lib/streamlit/elements/widgets/selectbox.py lines 542–557.
  • Reset logic is clear, but there is a protocol-level mismatch for format_func changes:

    • The frontend stores the selectbox value as a string (the formatted label) in WidgetStateManager, and will only update it from the server when element.setValue is true.
      • References:
        • frontend/lib/src/components/widgets/Selectbox/Selectbox.tsx lines 45–61 and 91–125.
        • frontend/lib/src/hooks/useBasicWidgetState.ts lines 198–208.
    • The backend deserializes the incoming UI value by looking it up in formatted_option_to_option_index. If the formatted strings change (e.g., format_func changes from capitalizeupper), the old formatted value is no longer in the mapping and is treated as a new string value.
      • Reference: lib/streamlit/elements/widgets/selectbox.py lines 580–596 (serde + register_widget), and SelectboxSerde.deserialize in the same file lines 134–146.
    • The new validation then checks membership against the raw opt list, so a previously valid selection can be incorrectly considered “not in options” and reset.
      • Reference: lib/streamlit/elements/widgets/selectbox.py lines 598–637.

    Consequence: despite the PR claim (and test docstrings), format_func changes are not reliably supported for preserving state with a stable key. The code currently tends to reset (or keep an “orphaned” string value on the frontend) rather than preserve the intended underlying option.

  • Minor clarity issue: The safe_index clamping is effectively redundant because index is validated against the current opt earlier in the function.

    • References: validation at lib/streamlit/elements/widgets/selectbox.py lines 525–529, and clamping at 612–615.

Test Coverage

  • Python unit tests: Good coverage added for option expansion, option shrink/removal reset behavior, and accept_new_options=True preserving non-option values.

    • Reference: lib/tests/streamlit/elements/selectbox_test.py lines 382–544.
  • Enum coercion test behavior changed: The unit test now expects behavior to succeed with runner.enumCoercion="off" rather than raising, aligning with the new “reset invalid value to default” behavior.

    • Reference: lib/tests/streamlit/elements/selectbox_test.py lines 546–597.
  • E2E tests: The updated dynamic test uses shared helpers (select_selectbox_option) and snapshots a focused widget element (good practice).

    • Reference: e2e_playwright/st_selectbox_test.py lines 265–321.
  • Key gap: the e2e “selection preserved” assertion is currently non-discriminating.

    • In test_dynamic_selectbox_props, the “preserved” case selects apple and then toggles back to the “initial” selectbox where the default is also apple (index=0). Even if the selection was actually reset rather than preserved, the assertion still passes.
    • Reference: e2e_playwright/st_selectbox_test.py lines 309–321.

    This means the test does not actually verify preservation across a formatting/options update, and will not catch the format_func mismatch described above.

Backwards Compatibility

  • Behavior change for keyed selectboxes: Previously, changing options with the same key caused the widget ID to change (state loss / remount). This PR makes it stable, which is likely desired and more intuitive.

  • However, claiming support for dynamic format_func changes is risky with the current string-based protocol. Users who change format_func while expecting stable selection may see unexpected resets or stale/orphaned UI values.

Security & Risk

  • No direct security concerns identified (no new I/O, permissions, or sensitive handling).

  • Regression risk:

    • The new “reset invalid value” path uses reset_state_value to mutate session state for the widget key. This is appropriate for avoiding the post-instantiation mutation error, but it increases the likelihood of subtle state changes during reruns.
      • Reference: lib/streamlit/elements/widgets/selectbox.py lines 619–623.
    • The biggest risk is correctness/UI consistency around format_func changes and the frontend’s stored string value.

Recommendations

  1. Fix or narrow the format_func support claim.

    • If the intent is to support format_func changes with stable selection, you likely need additional server-side mapping logic when deserialization fails (e.g., a safe fallback mapping strategy) or a protocol change so the frontend stores a stable identifier (e.g., index) rather than the formatted label string.
    • If that’s out of scope, update comments/tests/docs to explicitly state that dynamic options changes are supported but format_func changes are not reliably supported.
    • References: backend serde/deserialization lib/streamlit/elements/widgets/selectbox.py 134–146, 580–596; frontend setValue gating frontend/lib/src/hooks/useBasicWidgetState.ts 198–208.
  2. Strengthen test_dynamic_selectbox_props so it truly verifies preservation.

    • Ensure the “preserved” selection is not equal to the default after toggling, and exists in both option sets. For example, include a second overlapping value in both lists (e.g., add "mango" to the initial options) and select that before toggling.
    • Also consider asserting the displayed UI text changes when format_func changes, not only the returned Python value.
    • Reference: e2e_playwright/st_selectbox_test.py 309–321, plus app setup in e2e_playwright/st_selectbox.py 150–187.
  3. Consider simplifying/removing the redundant safe_index clamp and aligning the unit test docstring.

    • As written, safe_index = min(index, len(opt)-1) should never differ from index because index is already validated against len(opt).
    • References: lib/streamlit/elements/widgets/selectbox.py 525–529, 612–615; unit test docstring lib/tests/streamlit/elements/selectbox_test.py 458–464.

Verdict

CHANGES REQUESTED: The dynamic-options behavior looks good, but format_func changes are not reliably supported with the current frontend/backend value model, and the e2e test intended to validate “preserve” does not actually detect the problematic cases.


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

@github-actions github-actions bot added the do-not-merge PR is blocked from merging label Dec 16, 2025
@lukasmasuch lukasmasuch removed the do-not-merge PR is blocked from merging label Dec 16, 2025
@lukasmasuch lukasmasuch added the ai-review If applied to PR or issue will run AI review workflow label Dec 16, 2025
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Dec 16, 2025
@github-actions
Copy link
Contributor

Summary

This PR allows dynamically changing the options parameter of st.selectbox without triggering a widget identity change (state reset), addressing multiple user-reported issues (#6352, #8496, #4854). The implementation:

  1. Removes options from the key_as_main_identity set, so changing options no longer changes the widget ID when a key is provided.
  2. Adds validation logic to check if the currently selected value still exists in the new options list.
  3. If the value exists in new options → selection is preserved.
  4. If the value doesn't exist in new options → selection resets to the default index.
  5. Values entered via accept_new_options=True are preserved even if not in the current options list.

Code Quality

Strengths:

  • The validation logic is cleanly implemented and well-positioned after maybe_coerce_enum.
  • Appropriate use of reset_state_value to update session state without triggering the "cannot be modified after widget instantiated" error.
  • Good inline comments explaining the purpose of each code section.
  • The index_ function is reused for validation, maintaining consistency with existing codebase patterns.

Minor observations:

  1. In lib/tests/streamlit/elements/selectbox_test.py at lines 295-319, the comment "Whitelisted kwargs:" appears above accept_new_options=True in test_stable_id_with_key, but format_func and options are listed right above it without indicating they are no longer whitelisted. This could be slightly confusing since the comment structure suggests a separation between whitelisted and non-whitelisted kwargs.

  2. The enum coercion test (test_selectbox_enum_coercion at lines 543-593) has been updated with good docstrings explaining the expected behavior, but both test_enum_coercion_on and test_enum_coercion_off use identical assertions. While technically correct (both scenarios result in a value from the current class), adding an assertion to verify the actual value (e.g., that with coercion off, the value resets to the default EnumA.A rather than the selected EnumA.C) would make the test more explicit about the behavioral difference.

Test Coverage

Unit Tests (lib/tests/streamlit/elements/selectbox_test.py):

  • test_selectbox_preserves_selection_when_options_expand - Tests preservation when options are added
  • test_selectbox_resets_when_selection_removed - Tests reset when selected value is removed
  • test_selectbox_resets_when_options_shrink_significantly - Tests reset with significant option reduction
  • test_selectbox_preserves_custom_value_with_accept_new_options - Tests preservation with accept_new_options=True
  • ✅ Updated test_stable_id_with_key to verify options/format_func changes don't affect widget ID
  • ✅ Updated test_selectbox_enum_coercion with correct behavior for coercion off

E2E Tests (e2e_playwright/st_selectbox_test.py):

  • test_dynamic_selectbox_props comprehensively tests:
    • Selection reset when value is removed from options ("banana" → "papaya")
    • Selection preservation when value exists in both option sets ("mango" at different indices)
    • Format function changes are applied correctly (capitalize → upper)
  • ✅ Uses select_selectbox_option helper from app_utils (follows best practices)
  • ✅ Uses expect_prefixed_markdown for assertions (follows best practices)
  • ✅ Tests at non-default indices to verify true preservation vs. coincidental default

Test best practices compliance:

  • Tests follow Python test guide with docstrings (numpydoc style)
  • E2E tests use expect for assertions (reduces flakiness)
  • E2E tests use label-based locators (get_element_by_key, select_selectbox_option)

Note: Some unit tests require double .run() calls due to AppTest limitations in simulating frontend processing of set_value=True. This is properly documented in comments.

Backwards Compatibility

This change is backwards compatible.

Previous behavior: Changing options caused a widget identity change, resetting selection to default.

New behavior:

  • Selection is preserved if the value exists in the new options (improvement)
  • Selection is reset to default if the value doesn't exist (same outcome as before, different mechanism)

The only theoretical breaking change would be if someone intentionally relied on options changes to reset selection, which would be an unusual pattern. The new behavior is more intuitive and aligns with user expectations.

Security & Risk

No security concerns identified:

  • The validation logic uses defensive programming (try/except with proper fallback)
  • Session state updates use the proper reset_state_value API
  • No external inputs are processed unsafely

Regression risk: Low

  • The change is isolated to the selectbox widget
  • Comprehensive test coverage for edge cases
  • The validation only triggers when options actually change

Recommendations

  1. Consider adding explicit value assertion to enum test (optional): In test_selectbox_enum_coercion, the test_enum_coercion_off function could assert that the value actually reset to EnumA.A rather than staying at EnumA.C to make the test more explicit:
def test_enum_coercion_off():
    """With coercion disabled, the old class value is reset to new class default."""
    selectbox = at.selectbox[0]
    original_class = selectbox.value.__class__
    at = selectbox.set_value(original_class.C).run()
    # Value should reset to default (A) since old class C is not in new class options
    assert at.selectbox[0].value.name == "A", "Value should reset to default with coercion off"
    assert at.text[0].value == at.text[1].value, "Enum Class ID not the same"
    assert at.text[2].value == "True", "Not all enums found in class"
  1. Minor comment cleanup (optional): In test_stable_id_with_key, consider restructuring comments to clarify that format_func and options are no longer in the whitelist.

These are minor suggestions and do not block approval.

Verdict

APPROVED: This PR provides a well-implemented solution for dynamic selectbox options with comprehensive test coverage, proper backwards compatibility handling, and follows Streamlit code patterns. The changes are isolated, defensive, and address legitimate user needs documented in multiple issues.


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

@lukasmasuch lukasmasuch merged commit f8b3139 into develop Dec 19, 2025
43 checks passed
@lukasmasuch lukasmasuch deleted the feature/dynamic-selectbox-options branch December 19, 2025 16:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:feature PR contains new feature or enhancement implementation impact:users PR changes affect end users security-assessment-completed Security assessment has been completed for PR

Projects

None yet

3 participants