Skip to content

Allow dynamic changes to st.pills and st.segmented_control options when key is provided#13684

Merged
lukasmasuch merged 28 commits intodevelopfrom
lukasmasuch/pills-dynamic-options
Feb 5, 2026
Merged

Allow dynamic changes to st.pills and st.segmented_control options when key is provided#13684
lukasmasuch merged 28 commits intodevelopfrom
lukasmasuch/pills-dynamic-options

Conversation

@lukasmasuch
Copy link
Copy Markdown
Collaborator

@lukasmasuch lukasmasuch commented Jan 23, 2026

Describe your changes

Allow dynamically changing the options for st.segmented_control and st.pills 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.

This also applies a needed change from index-based widget value to string-based (formatted options). The same change was already implemented for st.selectbox and st.multiselect, and st.radio.

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.

@lukasmasuch lukasmasuch requested a review from a team as a code owner January 23, 2026 14:30
Copilot AI review requested due to automatic review settings January 23, 2026 14:30
@snyk-io
Copy link
Copy Markdown
Contributor

snyk-io bot commented Jan 23, 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 23, 2026

✅ PR preview is ready!

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

@lukasmasuch lukasmasuch added security-assessment-completed change:feature PR contains new feature or enhancement implementation impact:users PR changes affect end users labels Jan 23, 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 switches ButtonGroup widgets (feedback, pills, segmented_control) to use string-based values over the wire and tightens dynamic-options behavior and test coverage, especially for pills and segmented controls with changing options. It also clarifies and tests ID stability semantics when options and other props change.

Changes:

  • Extend the ButtonGroup protobuf with a raw_values field to carry string-based selections, and update Python/TS ButtonGroup implementations to consistently use string_array_value/raw_values instead of index-based value.
  • Replace the old index-based serde stack with a unified _ButtonGroupSerde that works with formatted strings and integrates with shared options_selector_utils validation utilities for dynamic options, including enum coercion and value-reset logic.
  • Expand backend unit tests, frontend unit tests, and e2e tests for pills and segmented_control to cover string-based serialization, dynamic option changes (including removal/preservation scenarios), and refined stable-ID expectations based on click_mode rather than option lists.

Reviewed changes

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

Show a summary per file
File Description
proto/streamlit/proto/ButtonGroup.proto Adds a repeated string raw_values = 14 field and bumps the next field ID, enabling string-based value transport for ButtonGroup widgets.
lib/tests/streamlit/elements/button_group_test.py Replaces legacy serde tests with coverage for _ButtonGroupSerde’s string-based behavior and expands ButtonGroup/pills/segmented_control tests to reflect new ID-stability and dynamic-options semantics.
lib/streamlit/elements/widgets/button_group.py Introduces _ButtonGroupSerde, switches ButtonGroup widgets to string-array widget state and raw_values, wires in options-mapping and validation helpers, and refines key_as_main_identity so pills/segmented_control IDs depend on click_mode while feedback still depends on both options and click mode.
frontend/lib/src/components/widgets/ButtonGroup/ButtonGroup.tsx Adds helpers to map between string values and option indices, updates widget state syncing to use setStringArrayValue, and reads current selection from rawValues instead of index-based value.
frontend/lib/src/components/widgets/ButtonGroup/ButtonGroup.test.tsx Adjusts expectations from int-array to string-array widget values and adds assertions around rawValues-based updates and fragment ID propagation.
e2e_playwright/st_segmented_control_test.py Refines the dynamic segmented control E2E test to explicitly validate reset vs. preservation behavior when options and defaults change, and documents the formatted-value-based preservation contract.
e2e_playwright/st_segmented_control.py Updates the dynamic segmented control demo app to use overlapping option sets (with mango) and new defaults that exercise both reset and preservation paths under changing options.
e2e_playwright/st_pills_test.py Mirrors the segmented control dynamic-options E2E coverage for st.pills, including reset when a selection disappears and preservation when a selection survives across different option lists.
e2e_playwright/st_pills.py Adjusts the dynamic pills demo to parallel the segmented control scenario (shared mango option, shifted indices, new defaults) and documents the formatted-label preservation behavior.

@lukasmasuch lukasmasuch changed the title Improve dynamic options coverage for button group Allow dynamic changes to st.pills and st.segmented_control options when key is provided Jan 23, 2026
@lukasmasuch
Copy link
Copy Markdown
Collaborator Author

@cursor review

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

Summary

This PR enables dynamic option changes for st.pills and st.segmented_control widgets without triggering a widget identity change or state reset. The implementation migrates from index-based widget values to string-based values (using formatted option strings), following the same pattern already implemented for st.selectbox, st.multiselect, and st.radio.

Key changes:

  • Added raw_values field to ButtonGroup.proto for string-based value representation
  • New _ButtonGroupSerde class for unified string-based serialization/deserialization
  • Changed key_as_main_identity from {"options", "click_mode"} to just {"click_mode"} for pills/segmented_control
  • Frontend updated to use setStringArrayValue and read from rawValues
  • When current selection is not in the new options, the widget resets to the default value; otherwise, selection is preserved

Code Quality

The code quality is high and follows Streamlit's established patterns well:

Strengths:

  • The implementation correctly mirrors the approach used for st.selectbox, st.multiselect, and st.radio, ensuring consistency across option-based widgets
  • The _ButtonGroupSerde class is well-structured with clear separation between single and multi-select modes
  • Good use of type hints throughout the Python code
  • Frontend code properly uses useMemo and useCallback for performance optimization
  • Helper functions (getFormattedOption, stringValuesToIndices, etc.) are well-named and have clear purposes

Minor observations:

  1. In button_group.py lines 152-163, the _serialize_single method falls back to str(v) when format_func fails. While this is defensive, it could lead to subtle mismatches if a value can be formatted on one render but not another. Consider logging a warning in this case.

  2. The feedback widget's format includes the button index ({icon} {content}|{button_idx}) to handle identical icons (stars), while pills/segmented_control use just the formatted label. This asymmetry is intentional and correct, but could benefit from a brief code comment explaining why.

Test Coverage

Test coverage is excellent and comprehensive:

Python Unit Tests (button_group_test.py):

  • ✅ New TestButtonGroupSerde class thoroughly tests the serde for single/multi modes, serialization, deserialization, defaults, and unknown values
  • ✅ Tests for ID stability with different parameters (options change without ID change, selection_mode change with ID change)
  • ✅ Proper use of @parameterized.expand for test variations
  • ✅ Includes negative assertions (e.g., testing that options change does NOT change ID)

Frontend Unit Tests (ButtonGroup.test.tsx):

  • ✅ Updated to verify setStringArrayValue calls with correct string format
  • ✅ Tests both single and multi-select modes with string values
  • ✅ Tests rawValues proto field usage
  • ✅ Uses proper RTL queries and userEvent

E2E Tests (st_pills_test.py, st_segmented_control_test.py):

  • ✅ Tests both key scenarios: selection reset when value removed, selection preserved when value exists
  • ✅ Excellent docstrings explaining test scenarios
  • ✅ Proper use of expect for auto-wait assertions
  • ✅ Key-based locators via get_element_by_key
  • ✅ Negative assertions (expect(dynamic_pills).not_to_contain_text("Banana"))
  • ✅ Visual snapshot testing for before/after states

Backwards Compatibility

Analysis:

  • The protobuf change adds a new raw_values field (field 14) - this is a safe, additive change
  • The existing value field (field 7) remains but is no longer actively used by the frontend
  • The default field continues to use index-based values for initial state

Risk Assessment:

  • Old frontend + New backend: Old frontend would read from the value field which is no longer populated. Widgets would fall back to defaults. This is acceptable since Streamlit requires matching frontend/backend versions.
  • New frontend + Old backend: New frontend reads from raw_values which old backend doesn't populate. Widgets fall back to defaults.
  • Both new: Works as intended with dynamic options support.

Recommendation: The value field in ButtonGroup.proto should have a deprecation comment added:

// DEPRECATED: Use raw_values instead for string-based value handling.
// Kept for backwards compatibility with older frontends.
repeated uint32 value = 7 [deprecated=true];

Security & Risk

No security concerns identified. The changes are well-contained to the button group widget implementation and follow established patterns in the codebase.

Regression risks are minimal:

  • The change to key_as_main_identity only affects pills/segmented_control (not feedback), which is correct
  • String-based serialization has been battle-tested in selectbox/multiselect/radio
  • Session state updates use reset_state_value to avoid the "cannot be modified after widget instantiated" error

Recommendations

  1. Consider adding deprecation marker to proto field: Add [deprecated=true] and a comment to the value field in ButtonGroup.proto to signal it's no longer the preferred approach. This is a minor documentation improvement.

  2. Documentation note: Consider adding a note to the widget docstrings mentioning that when using dynamic options with a key, selection is preserved based on the formatted label string. If format_func changes such that the same option has a different label, selection may be lost.

  3. Minor code comment: In button_group.py around line 488-501, consider adding a brief comment explaining why feedback uses a different format string (with index) compared to pills/segmented_control - to handle identical icons (stars).

Verdict

APPROVED: This is a well-implemented feature that follows established Streamlit patterns, has comprehensive test coverage across all testing layers (unit, frontend, e2e), and poses minimal backwards compatibility risk. The code quality is high and the changes are well-documented through tests. The minor recommendations above are suggestions for improvement but do not block merging.


This is an automated AI review using opus-4.5-thinking. Please verify the feedback and use your judgment.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Jan 23, 2026

📉 Python coverage change detected

The Python unit test coverage has decreased by 0.0407%

  • Current PR: 93.0444% (23420 statements, 1629 missed)
  • Latest develop: 93.0851% (23341 statements, 1614 missed)

✅ Coverage change is within normal range.

Coverage by files
Name Stmts Miss Cover
streamlit/__init__.py 136 0 100%
streamlit/__main__.py 3 3 0%
streamlit/auth_util.py 231 25 89%
streamlit/cli_util.py 39 6 85%
streamlit/column_config.py 3 0 100%
streamlit/commands/__init__.py 0 0 100%
streamlit/commands/echo.py 54 2 96%
streamlit/commands/execution_control.py 70 10 86%
streamlit/commands/logo.py 53 1 98%
streamlit/commands/navigation.py 106 2 98%
streamlit/commands/page_config.py 106 4 96%
streamlit/components/__init__.py 0 0 100%
streamlit/components/lib/__init__.py 0 0 100%
streamlit/components/lib/local_component_registry.py 35 2 94%
streamlit/components/types/__init__.py 0 0 100%
streamlit/components/types/base_component_registry.py 14 0 100%
streamlit/components/types/base_custom_component.py 48 6 88%
streamlit/components/v1/__init__.py 5 0 100%
streamlit/components/v1/component_arrow.py 33 2 94%
streamlit/components/v1/component_registry.py 41 3 93%
streamlit/components/v1/components.py 4 4 0%
streamlit/components/v1/custom_component.py 84 7 92%
streamlit/components/v2/__init__.py 27 0 100%
streamlit/components/v2/bidi_component/__init__.py 4 0 100%
streamlit/components/v2/bidi_component/constants.py 5 0 100%
streamlit/components/v2/bidi_component/main.py 148 17 89%
streamlit/components/v2/bidi_component/serialization.py 81 2 98%
streamlit/components/v2/bidi_component/state.py 13 0 100%
streamlit/components/v2/component_definition_resolver.py 30 0 100%
streamlit/components/v2/component_file_watcher.py 117 9 92%
streamlit/components/v2/component_manager.py 97 13 87%
streamlit/components/v2/component_manifest_handler.py 24 0 100%
streamlit/components/v2/component_path_utils.py 65 5 92%
streamlit/components/v2/component_registry.py 121 8 93%
streamlit/components/v2/get_bidi_component_manager.py 8 1 88%
streamlit/components/v2/manifest_scanner.py 227 25 89%
streamlit/components/v2/presentation.py 84 19 77%
streamlit/components/v2/types.py 8 8 0%
streamlit/config.py 417 12 97%
streamlit/config_option.py 79 3 96%
streamlit/config_util.py 308 7 98%
streamlit/connections/__init__.py 6 0 100%
streamlit/connections/base_connection.py 49 0 100%
streamlit/connections/snowflake_connection.py 98 16 84%
streamlit/connections/snowpark_connection.py 44 3 93%
streamlit/connections/sql_connection.py 56 6 89%
streamlit/connections/util.py 33 0 100%
streamlit/cursor.py 130 1 99%
streamlit/dataframe_util.py 506 47 91%
streamlit/delta_generator.py 251 7 97%
streamlit/delta_generator_singletons.py 74 7 91%
streamlit/deprecation_util.py 66 4 94%
streamlit/development.py 1 0 100%
streamlit/elements/__init__.py 0 0 100%
streamlit/elements/alert.py 60 0 100%
streamlit/elements/arrow.py 210 18 91%
streamlit/elements/balloons.py 10 0 100%
streamlit/elements/bokeh_chart.py 9 0 100%
streamlit/elements/code.py 20 1 95%
streamlit/elements/deck_gl_json_chart.py 104 7 93%
streamlit/elements/dialog_decorator.py 38 0 100%
streamlit/elements/doc_string.py 227 9 96%
streamlit/elements/empty.py 16 4 75%
streamlit/elements/exception.py 101 10 90%
streamlit/elements/form.py 56 2 96%
streamlit/elements/graphviz_chart.py 36 1 97%
streamlit/elements/heading.py 56 0 100%
streamlit/elements/html.py 49 0 100%
streamlit/elements/iframe.py 29 0 100%
streamlit/elements/image.py 32 0 100%
streamlit/elements/json.py 48 6 88%
streamlit/elements/layouts.py 140 3 98%
streamlit/elements/lib/__init__.py 0 0 100%
streamlit/elements/lib/built_in_chart_utils.py 403 30 93%
streamlit/elements/lib/color_util.py 103 4 96%
streamlit/elements/lib/column_config_utils.py 169 1 99%
streamlit/elements/lib/column_types.py 190 4 98%
streamlit/elements/lib/dialog.py 69 1 99%
streamlit/elements/lib/dicttools.py 39 2 95%
streamlit/elements/lib/file_uploader_utils.py 30 0 100%
streamlit/elements/lib/form_utils.py 26 0 100%
streamlit/elements/lib/image_utils.py 176 21 88%
streamlit/elements/lib/js_number.py 28 3 89%
streamlit/elements/lib/layout_utils.py 121 1 99%
streamlit/elements/lib/mutable_status_container.py 73 4 95%
streamlit/elements/lib/options_selector_utils.py 142 2 99%
streamlit/elements/lib/pandas_styler_utils.py 80 2 98%
streamlit/elements/lib/policies.py 56 1 98%
streamlit/elements/lib/shortcut_utils.py 42 2 95%
streamlit/elements/lib/streamlit_plotly_theme.py 48 0 100%
streamlit/elements/lib/subtitle_utils.py 76 5 93%
streamlit/elements/lib/utils.py 76 5 93%
streamlit/elements/map.py 110 1 99%
streamlit/elements/markdown.py 64 2 97%
streamlit/elements/media.py 181 8 96%
streamlit/elements/metric.py 104 0 100%
streamlit/elements/pdf.py 49 2 96%
streamlit/elements/plotly_chart.py 129 6 95%
streamlit/elements/progress.py 36 0 100%
streamlit/elements/pyplot.py 39 2 95%
streamlit/elements/snow.py 10 0 100%
streamlit/elements/space.py 12 0 100%
streamlit/elements/spinner.py 44 3 93%
streamlit/elements/text.py 16 0 100%
streamlit/elements/toast.py 26 0 100%
streamlit/elements/vega_charts.py 238 5 98%
streamlit/elements/widgets/__init__.py 0 0 100%
streamlit/elements/widgets/audio_input.py 68 1 99%
streamlit/elements/widgets/button.py 245 6 98%
streamlit/elements/widgets/button_group.py 207 15 93%
streamlit/elements/widgets/camera_input.py 62 1 98%
streamlit/elements/widgets/chat.py 237 38 84%
streamlit/elements/widgets/checkbox.py 52 0 100%
streamlit/elements/widgets/color_picker.py 59 2 97%
streamlit/elements/widgets/data_editor.py 254 14 94%
streamlit/elements/widgets/feedback.py 69 0 100%
streamlit/elements/widgets/file_uploader.py 108 10 91%
streamlit/elements/widgets/multiselect.py 114 5 96%
streamlit/elements/widgets/number_input.py 146 4 97%
streamlit/elements/widgets/radio.py 103 5 95%
streamlit/elements/widgets/select_slider.py 122 2 98%
streamlit/elements/widgets/selectbox.py 97 3 97%
streamlit/elements/widgets/slider.py 241 8 97%
streamlit/elements/widgets/text_widgets.py 130 6 95%
streamlit/elements/widgets/time_widgets.py 425 21 95%
streamlit/elements/write.py 166 20 88%
streamlit/emojis.py 4 0 100%
streamlit/env_util.py 21 3 86%
streamlit/error_util.py 33 2 94%
streamlit/errors.py 184 25 86%
streamlit/external/__init__.py 0 0 100%
streamlit/external/langchain/__init__.py 2 0 100%
streamlit/external/langchain/streamlit_callback_handler.py 141 82 42%
streamlit/file_util.py 84 8 90%
streamlit/git_util.py 100 5 95%
streamlit/logger.py 54 0 100%
streamlit/material_icon_names.py 1 0 100%
streamlit/navigation/__init__.py 0 0 100%
streamlit/navigation/page.py 78 2 97%
streamlit/net_util.py 55 3 95%
streamlit/path_security.py 17 1 94%
streamlit/platform.py 10 1 90%
streamlit/runtime/__init__.py 8 0 100%
streamlit/runtime/app_session.py 469 85 82%
streamlit/runtime/caching/__init__.py 21 0 100%
streamlit/runtime/caching/cache_data_api.py 191 3 98%
streamlit/runtime/caching/cache_errors.py 44 4 91%
streamlit/runtime/caching/cache_resource_api.py 165 1 99%
streamlit/runtime/caching/cache_type.py 11 1 91%
streamlit/runtime/caching/cache_utils.py 176 9 95%
streamlit/runtime/caching/cached_message_replay.py 108 1 99%
streamlit/runtime/caching/hashing.py 310 25 92%
streamlit/runtime/caching/legacy_cache_api.py 14 0 100%
streamlit/runtime/caching/storage/__init__.py 2 0 100%
streamlit/runtime/caching/storage/cache_storage_protocol.py 29 0 100%
streamlit/runtime/caching/storage/dummy_cache_storage.py 21 0 100%
streamlit/runtime/caching/storage/in_memory_cache_storage_wrapper.py 67 1 99%
streamlit/runtime/caching/storage/local_disk_cache_storage.py 86 4 95%
streamlit/runtime/caching/ttl_cleanup_cache.py 28 0 100%
streamlit/runtime/connection_factory.py 96 11 89%
streamlit/runtime/context.py 137 0 100%
streamlit/runtime/context_util.py 18 0 100%
streamlit/runtime/credentials.py 139 4 97%
streamlit/runtime/download_data_util.py 27 0 100%
streamlit/runtime/forward_msg_cache.py 23 2 91%
streamlit/runtime/forward_msg_queue.py 63 4 94%
streamlit/runtime/fragment.py 112 2 98%
streamlit/runtime/media_file_manager.py 110 7 94%
streamlit/runtime/media_file_storage.py 15 0 100%
streamlit/runtime/memory_media_file_storage.py 73 0 100%
streamlit/runtime/memory_session_storage.py 15 0 100%
streamlit/runtime/memory_uploaded_file_manager.py 46 1 98%
streamlit/runtime/metrics_util.py 195 13 93%
streamlit/runtime/pages_manager.py 59 2 97%
streamlit/runtime/runtime.py 252 16 94%
streamlit/runtime/runtime_util.py 30 1 97%
streamlit/runtime/script_data.py 16 0 100%
streamlit/runtime/scriptrunner/__init__.py 5 0 100%
streamlit/runtime/scriptrunner/exec_code.py 49 5 90%
streamlit/runtime/scriptrunner/magic.py 83 1 99%
streamlit/runtime/scriptrunner/magic_funcs.py 10 1 90%
streamlit/runtime/scriptrunner/script_cache.py 27 0 100%
streamlit/runtime/scriptrunner/script_runner.py 235 27 89%
streamlit/runtime/scriptrunner_utils/__init__.py 0 0 100%
streamlit/runtime/scriptrunner_utils/exceptions.py 9 1 89%
streamlit/runtime/scriptrunner_utils/script_requests.py 106 5 95%
streamlit/runtime/scriptrunner_utils/script_run_context.py 118 0 100%
streamlit/runtime/secrets.py 241 25 90%
streamlit/runtime/session_manager.py 71 2 97%
streamlit/runtime/state/__init__.py 7 0 100%
streamlit/runtime/state/common.py 55 1 98%
streamlit/runtime/state/presentation.py 19 4 79%
streamlit/runtime/state/query_params.py 274 6 98%
streamlit/runtime/state/query_params_proxy.py 71 0 100%
streamlit/runtime/state/safe_session_state.py 77 9 88%
streamlit/runtime/state/session_state.py 503 36 93%
streamlit/runtime/state/session_state_proxy.py 62 8 87%
streamlit/runtime/state/widgets.py 19 0 100%
streamlit/runtime/stats.py 132 4 97%
streamlit/runtime/theme_util.py 46 1 98%
streamlit/runtime/uploaded_file_manager.py 39 3 92%
streamlit/runtime/websocket_session_manager.py 116 0 100%
streamlit/source_util.py 36 1 97%
streamlit/starlette.py 2 0 100%
streamlit/string_util.py 93 9 90%
streamlit/temporary_directory.py 18 1 94%
streamlit/testing/__init__.py 0 0 100%
streamlit/testing/v1/__init__.py 2 0 100%
streamlit/testing/v1/app_test.py 245 5 98%
streamlit/testing/v1/element_tree.py 1409 85 94%
streamlit/testing/v1/local_script_runner.py 71 2 97%
streamlit/testing/v1/util.py 17 0 100%
streamlit/time_util.py 28 1 96%
streamlit/type_util.py 148 16 89%
streamlit/url_util.py 39 4 90%
streamlit/user_info.py 105 8 92%
streamlit/util.py 38 1 97%
streamlit/version.py 3 0 100%
streamlit/watcher/__init__.py 3 0 100%
streamlit/watcher/event_based_path_watcher.py 184 25 86%
streamlit/watcher/folder_black_list.py 14 1 93%
streamlit/watcher/local_sources_watcher.py 127 9 93%
streamlit/watcher/path_watcher.py 42 3 93%
streamlit/watcher/polling_path_watcher.py 55 2 96%
streamlit/watcher/util.py 59 1 98%
streamlit/web/__init__.py 0 0 100%
streamlit/web/bootstrap.py 174 21 88%
streamlit/web/cache_storage_manager_config.py 5 0 100%
streamlit/web/cli.py 188 16 91%
streamlit/web/server/__init__.py 5 0 100%
streamlit/web/server/app_discovery.py 104 5 95%
streamlit/web/server/app_static_file_handler.py 35 3 91%
streamlit/web/server/authlib_tornado_integration.py 42 5 88%
streamlit/web/server/bidi_component_request_handler.py 65 8 88%
streamlit/web/server/browser_websocket_handler.py 147 20 86%
streamlit/web/server/component_file_utils.py 27 0 100%
streamlit/web/server/component_request_handler.py 55 4 93%
streamlit/web/server/media_file_handler.py 65 9 86%
streamlit/web/server/oauth_authlib_routes.py 162 35 78%
streamlit/web/server/oidc_mixin.py 46 0 100%
streamlit/web/server/routes.py 95 10 89%
streamlit/web/server/server.py 195 13 93%
streamlit/web/server/server_util.py 72 5 93%
streamlit/web/server/starlette/__init__.py 3 0 100%
streamlit/web/server/starlette/starlette_app.py 148 4 97%
streamlit/web/server/starlette/starlette_app_utils.py 101 7 93%
streamlit/web/server/starlette/starlette_auth_routes.py 233 51 78%
streamlit/web/server/starlette/starlette_gzip_middleware.py 30 0 100%
streamlit/web/server/starlette/starlette_path_security_middleware.py 22 0 100%
streamlit/web/server/starlette/starlette_routes.py 346 86 75%
streamlit/web/server/starlette/starlette_server.py 167 7 96%
streamlit/web/server/starlette/starlette_server_config.py 13 0 100%
streamlit/web/server/starlette/starlette_static_routes.py 70 6 91%
streamlit/web/server/starlette/starlette_websocket.py 203 23 89%
streamlit/web/server/stats_request_handler.py 59 5 92%
streamlit/web/server/upload_file_request_handler.py 59 7 88%
streamlit/web/server/websocket_headers.py 19 1 95%
TOTAL 23420 1629 93%

📊 View detailed coverage comparison

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Jan 23, 2026

📉 Frontend coverage change detected

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

  • Current PR: 86.4500% (13862 lines, 1877 missed)
  • Latest develop: 86.4600% (13843 lines, 1874 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

lukasmasuch and others added 2 commits February 3, 2026 01:19
Resolved conflicts:
- button_group.py: Kept string-based serde for dynamic options, removed
  feedback method (now extracted to separate module)
- button_group_test.py: Kept ButtonGroupSerde tests, removed feedback
  tests (moved to feedback_test.py)
- ButtonGroup.proto: Kept raw_values field for string-based wire format
…vation

- Store content strings in React state instead of indices
- Derive selectedIndices from content strings via useMemo
- This allows options to change dynamically without losing selection

Co-Authored-By: Claude (claude-opus-4-5) <[email protected]>
The old index-based 'value' field is no longer used since we switched
to string-based 'raw_values' for dynamic options support. Reserved the
field number (7) and name to maintain proto compatibility.

Co-Authored-By: Claude (claude-opus-4-5) <[email protected]>
@github-actions github-actions bot added the do-not-merge PR is blocked from merging label Feb 3, 2026
lukasmasuch and others added 3 commits February 3, 2026 01:55
Add validation to raise StreamlitAPIException when duplicate labels are
detected, either from raw options or via format_func. This prevents
ambiguous selection behavior where duplicate labels could map to the
wrong option index.

Co-Authored-By: Claude (claude-opus-4-5) <[email protected]>
Allow duplicate labels in st.pills and st.segmented_control with "last
one wins" behavior, matching how radio, selectbox, and multiselect
handle this case.

Co-Authored-By: Claude (claude-opus-4-5) <[email protected]>
@streamlit streamlit deleted a comment from github-actions bot Feb 3, 2026
@lukasmasuch lukasmasuch added ai-review If applied to PR or issue will run AI review workflow and removed do-not-merge PR is blocked from merging labels Feb 3, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Feb 3, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 3, 2026

Summary

This PR enables dynamic option changes for st.pills and st.segmented_control widgets when a user-provided key is present. Previously, changing options would reset the widget state; now selections are preserved when the selected value exists in the new options list, and gracefully reset to the default when the selected value is no longer available.

The implementation switches from index-based to string-based widget values, aligning with the approach already used by st.selectbox, st.multiselect, and st.radio. This is a significant but well-executed change that improves the developer experience for dynamic UIs.

Key changes:

  • Backend: New _ButtonGroupSerde class with string-based serialization
  • Frontend: State now stores content strings (e.g., ["Apple"]) instead of indices, with indices derived via useMemo
  • Proto: Replaced value (uint32 indices) with raw_values (string values)
  • Widget ID now only includes click_mode in key_as_main_identity, allowing options to change without ID reset

Code Quality

The code is well-structured and follows established patterns in the codebase:

Strengths:

  • The _ButtonGroupSerde class (lines 83-260 in button_group.py) is well-organized with clear separation between single and multi-select logic
  • Good reuse of existing utilities from options_selector_utils.py (validate_and_sync_value_with_options, validate_and_sync_multiselect_value_with_options)
  • Frontend correctly uses useMemo to derive indices from content strings (line 331-334 in ButtonGroup.tsx)
  • Helpful comments explaining design decisions (e.g., lines 976-979 in button_group.py explaining key_as_main_identity)

Minor observations:

  1. The index suffix format {content}|{index} (line 829 in button_group.py) is an implementation detail for uniqueness. While functional, content containing |{digit} at the end could theoretically cause edge cases. The implementation handles this correctly with rsplit("|", 1), but it's worth noting.

  2. The frontend stripIndexSuffix function (lines 78-81 in ButtonGroup.tsx) assumes the index is always at the end after a |. This mirrors the backend logic correctly.

Test Coverage

The test coverage is excellent and comprehensive:

Python Unit Tests (button_group_test.py):

  • New TestButtonGroupSerde class with 10 tests covering single/multi-select serialization and deserialization
  • Tests for ID stability with key (test_stable_id_with_key_pills, test_stable_id_with_key_segmented_control)
  • Tests verifying options can change without changing ID (test_options_change_does_not_change_id_pills, test_options_change_does_not_change_id_segmented_control)
  • Tests confirming selection_mode changes still trigger ID changes

Frontend Unit Tests (ButtonGroup.test.tsx):

  • Updated to use setStringArrayValue instead of setIntArrayValue
  • Tests for single-select and multi-select click behavior with string values
  • Tests for form reset behavior

E2E Tests (st_pills_test.py, st_segmented_control_test.py):

  • test_dynamic_pills_props and test_dynamic_segmented_control_props comprehensively test:
    • Selection reset when value is removed from options
    • Selection preservation when value exists in both option sets
    • Negative assertions to prevent regressions
  • Snapshot tests for both initial and updated states across all browsers (chromium, firefox, webkit)

The tests follow best practices from .cursor/rules/e2e_playwright.mdc and .cursor/rules/python_tests.mdc, including negative assertions and clear docstrings explaining test scenarios.

Backwards Compatibility

Protocol changes are properly handled:

  • Field 7 (value) is now reserved, not reused
  • New field 14 (raw_values) is added with clear documentation
  • The next id comment is updated to 15

Impact on existing users:

  • Users upgrading Streamlit will see their pills/segmented_control widgets work seamlessly
  • No breaking changes to the public API (st.pills, st.segmented_control)
  • The change is backwards compatible from a user's perspective - existing code continues to work

Note: This is a wire format change, so mixed-version deployments (old frontend + new backend or vice versa) would have issues. This is expected and acceptable for Streamlit upgrades.

Security & Risk

No security concerns identified:

  • No user input is executed or evaluated unsafely
  • The format_func is already validated and used consistently
  • String values are properly escaped/handled in protobuf

Low regression risk:

  • The implementation mirrors the proven approach from st.selectbox/st.multiselect/st.radio
  • Comprehensive test coverage protects against regressions
  • E2E tests verify end-to-end behavior

Recommendations

No blocking issues identified. A few optional suggestions:

  1. Documentation (optional): Consider adding a note to the docstrings for st.pills and st.segmented_control mentioning that when a key is provided, options can be changed dynamically while preserving valid selections.

  2. Edge case handling (optional): The test app comments (e.g., line 211-214 in st_pills.py) explain that format_func changes affect selection preservation. This is documented in comments but users might benefit from explicit documentation.

Verdict

APPROVED: This is a well-implemented feature that aligns with existing patterns in the codebase, has comprehensive test coverage, and properly handles backwards compatibility. The change addresses user needs (issues #11277 and #12392) while maintaining code quality standards.


This is an automated AI review using opus-4.5-thinking. Please verify the feedback and use your judgment.

lukasmasuch and others added 11 commits February 3, 2026 03:11
…ultiselect

Simplify the pills/segmented_control serialization to use plain content
strings instead of "content|index" format. This matches how radio,
selectbox, and multiselect handle string-based state:

- Backend mapping uses formatted string as key (last wins for duplicates)
- Frontend stores and sends plain content strings
- No index suffix stripping needed

Co-Authored-By: Claude (claude-opus-4-5) <[email protected]>
- Inline key_as_main_identity set directly in function call
- Remove "Always use string-based values" comment that states the obvious

Co-Authored-By: Claude (claude-opus-4-5) <[email protected]>
Co-Authored-By: Claude (claude-opus-4-5) <[email protected]>
- Remove if-else branches that did the same thing in both _serialize_single and _serialize_multi
- Remove "mirrors radio/selectbox/multiselect" reference from docstring

Co-Authored-By: Claude (claude-opus-4-5) <[email protected]>
The test "sets widget value on update" was incorrectly expecting the
default value (icon_3) instead of the provided rawValues (icon_4).
- Extract getSelectionMode helper with switch statement
- Simplify getContentElement return using shorthand notation
Refactor _ButtonGroupSerde into _SingleSelectButtonGroupSerde and
_MultiSelectButtonGroupSerde for cleaner typing and reduced casts.
This eliminates 5 type casts by having separate classes with precise
type signatures for each selection mode.
- Refactor _internal_button_group to use single _button_group call
- Remove redundant comment from ButtonGroup.tsx onClick handler
- Fix single-select deselection: distinguish between None (initial state,
  use default) and empty list (explicit deselection, return None)
- Align frontend to backend's "last wins" behavior for duplicate labels
  in both ButtonGroup and Radio widgets
- Add test for explicit deselection scenario

This tests that:
1. Options can be changed dynamically when a key is provided
2. Format function can be changed dynamically
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.

Nit: Im not seeing that this tests the format_func changing

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Removed this comment, it's a bit more complicated for format_func. Changing the formatting won't reset the component, but will cause the value to be reset to the default if the currently selected formatted value isn't part of the new formatted options. Which didn't made it useable in this test.

The test doesn't actually change format_func dynamically - both initial
and updated states use the same capitalize function.
@lukasmasuch lukasmasuch enabled auto-merge (squash) February 5, 2026 01:31
@lukasmasuch lukasmasuch merged commit e6947d3 into develop Feb 5, 2026
43 checks passed
@lukasmasuch lukasmasuch deleted the lukasmasuch/pills-dynamic-options branch February 5, 2026 01:40
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

Projects

None yet

3 participants