Skip to content

Bind widgets to query params - Part 1#13681

Merged
mayagbarnes merged 8 commits intodevelopfrom
query-param-bind-1
Jan 26, 2026
Merged

Bind widgets to query params - Part 1#13681
mayagbarnes merged 8 commits intodevelopfrom
query-param-bind-1

Conversation

@mayagbarnes
Copy link
Copy Markdown
Collaborator

@mayagbarnes mayagbarnes commented Jan 23, 2026

Describe your changes

Bind Widgets to Query Params - Part 1
This PR adds the backend infrastructure for binding widgets to query parameters - enabling two-way sync between applicable Streamlit widgets and URL's query parameters.

  • Protected params - Blocks binding to embed / embed_options reserved params
  • Direct manipulation protection - Raises exception when code tries to set/delete bound params directly
  • Binding registration - bind_widget() / unbind_widget() to track widget-to-param relationships
  • URL parsing - parse_url_param() handles all widget value types (bool, int, float, string, arrays)
  • URL seeding - get_initial_value() retrieves URL values to seed session state on page load
  • Auto-correction - _set_corrected_value() updates the URL when widgets clamp/validate values
  • MPA support - populate_from_query_string() handles page transitions
  • Stale cleanup - remove_stale_bindings() removes URL params when conditional widgets unmount

Testing Plan

  • Python Unit Tests: ✅ Added
  • Manual Testing: Via widgets in downstream PRs & with prototype

@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-13681/streamlit-1.53.1-py3-none-any.whl
📦 @streamlit/component-v2-lib Download from artifacts
🕹️ Preview app pr-13681.streamlit.app (☁️ Deploy here if not accessible)

Copy link
Copy Markdown
Collaborator Author

mayagbarnes commented Jan 23, 2026

@mayagbarnes mayagbarnes added the change:feature PR contains new feature or enhancement implementation label Jan 23, 2026 — with Graphite App
@mayagbarnes mayagbarnes changed the title Translate prototype code Bind widgets to query params - Part 1 Jan 23, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Jan 23, 2026

📈 Python coverage change detected

The Python unit test coverage has increased by 0.0640%

  • Current PR: 93.1079% (23099 statements, 1592 missed)
  • Latest develop: 93.0439% (22872 statements, 1591 missed)

🎉 Great job on improving test coverage!

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 41 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 68 5 93%
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 415 12 97%
streamlit/config_option.py 79 3 96%
streamlit/config_util.py 288 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 250 7 97%
streamlit/delta_generator_singletons.py 74 7 91%
streamlit/deprecation_util.py 59 4 93%
streamlit/development.py 1 0 100%
streamlit/elements/__init__.py 0 0 100%
streamlit/elements/alert.py 60 0 100%
streamlit/elements/arrow.py 203 15 93%
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 10 90%
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 391 26 93%
streamlit/elements/lib/color_util.py 100 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 130 2 98%
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 65 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 3 99%
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 171 1 99%
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/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 97 0 100%
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/platform.py 10 1 90%
streamlit/runtime/__init__.py 8 0 100%
streamlit/runtime/app_session.py 456 85 81%
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 253 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 242 5 98%
streamlit/testing/v1/element_tree.py 1370 81 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 29 3 90%
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 18 88%
streamlit/web/server/component_file_utils.py 24 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 33 80%
streamlit/web/server/oidc_mixin.py 46 0 100%
streamlit/web/server/routes.py 90 7 92%
streamlit/web/server/server.py 195 13 93%
streamlit/web/server/server_util.py 68 5 93%
streamlit/web/server/starlette/__init__.py 3 0 100%
streamlit/web/server/starlette/starlette_app.py 146 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_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 64 3 95%
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 23099 1592 93%

📊 View detailed coverage comparison

@mayagbarnes mayagbarnes marked this pull request as ready for review January 23, 2026 22:00
Copilot AI review requested due to automatic review settings January 23, 2026 22:00
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 introduces backend infrastructure to bind widget values to URL query parameters, including binding metadata, parsing, seeding, and lifecycle management, plus comprehensive unit tests.

Changes:

  • Extend widget registration and metadata (BindOption, formatted_options) to support bind="query-params" with key validation.
  • Enhance QueryParams and SessionState to manage widget–param bindings, parse URL values per widget type, seed session state from URLs, auto-correct query strings, protect bound params from direct mutation, and handle MPA/page-fragment transitions.
  • Add extensive unit tests covering parsing, binding registration, protection semantics, URL seeding/correction, MPA page filtering, and stale-binding cleanup.

Reviewed changes

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

Show a summary per file
File Description
lib/streamlit/runtime/state/common.py Introduces BindOption type and extends WidgetMetadata with bind and formatted_options to carry binding configuration and option labels.
lib/streamlit/runtime/state/widgets.py Extends register_widget to accept bind/formatted_options and validates that bind="query-params" is only allowed when a user key is present.
lib/streamlit/runtime/state/query_params.py Adds WidgetBinding, parse_url_param, binding registries, protected param enforcement, direct-manipulation protection, initial-URL storage, corrected-value writing, MPA-aware populate_from_query_string, and remove_stale_bindings.
lib/streamlit/runtime/state/session_state.py Wires query-param binding into widget registration, adds URL seeding (_handle_query_param_binding, _seed_widget_from_url), URL auto-correction, invalid-value clearing, and invokes remove_stale_bindings during widget cleanup.
lib/streamlit/runtime/state/__init__.py Re-exports BindOption from the state package for public use.
lib/streamlit/runtime/scriptrunner_utils/script_run_context.py Changes reset to track same-page vs page-transition runs and to populate _query_params from the URL only on same-page reruns while always capturing initial query params for seeding.
lib/streamlit/runtime/scriptrunner/script_runner.py On MPA page transitions, pre-filters query params via populate_from_query_string with allowed script hashes before calling normal script-finish cleanup.
lib/streamlit/runtime/state/query_params_proxy.py Ensures the proxy continues to delegate to the enhanced QueryParams implementation without API changes.
lib/tests/streamlit/runtime/state/widgets_test.py Adds tests verifying that bind="query-params" requires a widget key, succeeds with a key, and that the default bind=None does not require one.
lib/tests/streamlit/runtime/state/session_state_test.py Adds tests for URL seeding precedence rules, URL parsing/clearing behavior, and URL auto-correction via SessionState’s new helper methods.
lib/tests/streamlit/runtime/state/query_params_test.py Adds tests for parse_url_param, binding registration and protection, direct manipulation safeguards, initial URL handling, corrected value formatting, MPA-aware population, and stale-binding removal across fragments/pages.
Comments suppressed due to low confidence (1)

lib/streamlit/runtime/scriptrunner/script_runner.py:552

  • The MPA page-transition logic filters query params via qp.populate_from_query_string(rerun_data.query_string, valid_script_hashes) and sends an updated page_info_changed.query_string, but the subsequent ctx.reset(...) call still passes the original rerun_data.query_string. This means QueryParams.set_initial_query_params (called inside ScriptRunContext.reset) can see and store unfiltered params for URL seeding, so widgets on the new page that reuse a key from a previous page can still be seeded from params that were intentionally filtered out of _query_params and removed from the URL. To keep URL seeding consistent with the filtered query params, reset should receive the filtered query string (for example, the one stored on ctx after _send_query_param_msg) so that _initial_query_params and _query_params stay in sync after page transitions.
                # For MPA page transitions: filter query params BEFORE cleanup.
                # This uses existing bindings to remove params from other pages,
                # ensuring st.query_params is accurate when the new page runs.
                # (st.query_params in user code will reflect the correct params for the new page)
                main_script_hash = self._pages_manager.main_script_hash
                valid_script_hashes = {main_script_hash, page_script_hash}
                with self._session_state.query_params() as qp:
                    qp.populate_from_query_string(
                        rerun_data.query_string, valid_script_hashes
                    )

                # Now safe to do normal cleanup - filtering already done
                self._session_state.on_script_finished(widget_ids)

            fragment_ids_this_run: list[str] | None = (
                rerun_data.fragment_id_queue or None
            )

            ctx.reset(
                query_string=rerun_data.query_string,
                page_script_hash=page_script_hash,

@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.0000%

  • Current PR: 86.2800% (13504 lines, 1852 missed)
  • Latest develop: 86.2800% (13504 lines, 1852 missed)

✅ Coverage change is within normal range.

📊 View detailed coverage comparison

@lukasmasuch
Copy link
Copy Markdown
Collaborator

@cursor review

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.

@streamlit streamlit deleted a comment from github-actions bot Jan 24, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

Adds widget-to-query-param binding infrastructure, including URL parsing/auto-correction, binding registries, and cleanup for stale widgets and MPA page transitions, plus unit tests covering parsing, binding, and seeding behavior.

Code Quality

There is a correctness gap around MPA page transitions: query params are filtered before cleanup, but the "initial" query params used for widget seeding are still taken from the unfiltered query string. This can allow stale params from the previous page to seed widgets on the new page if keys collide, even though st.query_params was filtered.

                # For MPA page transitions: filter query params BEFORE cleanup.
                # This uses existing bindings to remove params from other pages,
                # ensuring st.query_params is accurate when the new page runs.
                # (st.query_params in user code will reflect the correct params for the new page)
                main_script_hash = self._pages_manager.main_script_hash
                valid_script_hashes = {main_script_hash, page_script_hash}
                with self._session_state.query_params() as qp:
                    qp.populate_from_query_string(
                        rerun_data.query_string, valid_script_hashes
                    )
        with self.session_state.query_params() as qp:
            # Always update initial query params for widget seeding.
            # Widgets with bind="query-params" read from this on first render.
            qp.set_initial_query_params(query_string)

            # For same-page reruns (widget interactions), populate _query_params from URL.
            # For page transitions, populate_from_query_string() is called in script_runner.py
            # BEFORE reset() with valid_script_hashes to filter params from other pages.
            if is_same_page:
                qp.populate_from_query_string(query_string)

Test Coverage

Good unit coverage for parsing, binding lifecycle, and seeding/auto-correction behaviors in query_params_test.py, session_state_test.py, and widgets_test.py. There is no test that exercises a page transition with filtered params and verifies that widget seeding uses the filtered set; this is the scenario at risk.

Backwards Compatibility

The changes are additive and gated behind the new bind="query-params" option. Existing apps should behave the same unless they opt into binding. The new protections that prevent direct mutation of bound query params are appropriate for the new feature.

Security & Risk

Primary risk is correctness: stale query params from a previous page could seed widgets on the new page during MPA navigation if keys overlap, leading to unexpected state.

Recommendations

  1. Ensure set_initial_query_params uses the filtered query params on page transitions (e.g., pass a filtered query string into reset, or set _initial_query_params from the filtered QueryParams after populate_from_query_string runs) and add a unit test that reproduces the cross-page seeding case.

Verdict

CHANGES REQUESTED: Fix page-transition seeding to avoid stale query params influencing widgets, and add a focused test to prevent regressions.


This is an automated AI review using gpt-5.2-codex-high. Please verify the feedback and use your judgment.

@github-actions github-actions bot added the do-not-merge PR is blocked from merging label Jan 24, 2026
@mayagbarnes mayagbarnes removed the do-not-merge PR is blocked from merging label Jan 25, 2026
Comment on lines +91 to +96
formatted_options : list[str] or None
Optional list of formatted option strings for selection widgets
(radio, selectbox, multiselect, pills, segmented_control, select_slider).
Used for query param binding to support human-readable option strings
in URLs (e.g., ?color=Red instead of ?color=0) and to auto-correct
URLs when invalid options are filtered out.
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.

nitpick: 1.54 will likely have all selection widgets migrated to raw string states (formatted labels), but we likely still need this for other aspects, right?

Copy link
Copy Markdown
Collaborator Author

@mayagbarnes mayagbarnes Jan 26, 2026

Choose a reason for hiding this comment

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

No we should be able to remove the following pieces once conversion complete:

  • formatted_options param from register_widget() (here)
  • formatted_options field from WidgetMetadata (noted below)
  • The string_option_types auto-correction block in _auto_correct_url_if_needed()

Can I add as TODO and remove in follow up or should I wait on your PRs to merge these?


# Optional formatted options for selection widgets (radio, selectbox, multiselect,
# pills, segmented_control, select_slider). Used for query param binding to:
# 1. Support human-readable option strings in URLs (e.g., ?color=Red instead of ?color=0)
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.

See comment above, the need for this will likely go away since radio, selectbox, and multiselect are already using formatted labels in transferred widget stats. pills and segmented_control will be updated here: #13684, and select_slider most likely also this week.

Comment on lines +88 to +126
case "int_value":
# Try to parse as int, but return string if it fails.
# This intentionally differs from double_value (which raises on failure)
# because int_value is used for selection widgets where URLs may contain
# human-readable option strings (e.g., ?fruit=apple instead of ?fruit=0).
# The deserializer will match the string against widget options.
try:
return int(val)
except ValueError:
return val
case "double_value":
return float(val)
case "string_value":
return val
case "string_array_value":
# Repeated params: ?foo=a&foo=b -> ["a", "b"]
return list(value) if isinstance(value, list) else [value]
case "double_array_value":
# Repeated params: ?foo=1.5&foo=2.5 -> [1.5, 2.5]
# Also handles string values for select_slider option matching
parts = list(value) if isinstance(value, list) else [value]
result_double: list[float | str] = []
for part in parts:
try:
result_double.append(float(part))
except ValueError: # noqa: PERF203
result_double.append(part) # Keep as string for select_slider
return result_double
case "int_array_value":
# Repeated params: ?foo=1&foo=2 -> [1, 2]
# Also handles string values for option matching (pills, etc.)
parts = list(value) if isinstance(value, list) else [value]
result_int: list[int | str] = []
for part in parts:
try:
result_int.append(int(part))
except ValueError: # noqa: PERF203
result_int.append(part) # Keep as string
return result_int
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.

Same as the other comments, index-based usage for selection widgets will likely be removed in 1.54 / this week. It will always use formatted labels as string_value and string_array_value. This might need a few tweaks here, but not sure about the best way to approach it. E.g. merge in the change for pills/segemented_control and select_slider first and tweaks this, or merge this in first and tweak it as follow up.

Comment on lines +102 to +125
case "string_array_value":
# Repeated params: ?foo=a&foo=b -> ["a", "b"]
return list(value) if isinstance(value, list) else [value]
case "double_array_value":
# Repeated params: ?foo=1.5&foo=2.5 -> [1.5, 2.5]
# Also handles string values for select_slider option matching
parts = list(value) if isinstance(value, list) else [value]
result_double: list[float | str] = []
for part in parts:
try:
result_double.append(float(part))
except ValueError: # noqa: PERF203
result_double.append(part) # Keep as string for select_slider
return result_double
case "int_array_value":
# Repeated params: ?foo=1&foo=2 -> [1, 2]
# Also handles string values for option matching (pills, etc.)
parts = list(value) if isinstance(value, list) else [value]
result_int: list[int | str] = []
for part in parts:
try:
result_int.append(int(part))
except ValueError: # noqa: PERF203
result_int.append(part) # Keep as string
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.

question: Does this currently support empty lists? E.g. if the user explicitly wants multiselect / pills / segmented control to not contain any item. This is different from not specifying it at all since this would just use the defined default value. Probably specified just via ?foo=&other_query_param=foo

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.

The current behavior is that the developer's default value serves as the fallback for missing, invalid, or empty URL params (?foo= will result in default).
If a developer wants "nothing selected" to be a valid option for that widget, they set default=[]. If they set default=["Python"], they're declaring that empty isn't a valid initial state - the URL either specifies valid selections or falls back to their default.

Copy link
Copy Markdown
Collaborator

@lukasmasuch lukasmasuch Jan 26, 2026

Choose a reason for hiding this comment

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

For multiselect, pills, segmented_control, being empty is a valid state even if a default is set:

st.multiselect("Select items", default=["Python"])
# -> it's valid to remove all items from the selection -> state = []

This is a bit different from e.g. selectbox, where clearing is only supported if index=None. Supporting empty arrays - e.g. via ?foo= - might be worth considering to cover the full range of valid values.

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.

Fair enough, will explore this a bit more

Copy link
Copy Markdown
Collaborator

@lukasmasuch lukasmasuch left a comment

Choose a reason for hiding this comment

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

Overall, this looks good 👍 The index-based selection support might benefit from some simplifications/clean-up as soon as all selection widgets are migrated to use strings as state format:

Remaining widgets:

  • This will handle pills and segmented_control: #13684
  • Will probably open a PR for select_slider by tomorrow.

But not sure what's the best merge order to do that.

@mayagbarnes mayagbarnes merged commit 429841c into develop Jan 26, 2026
42 checks passed
@mayagbarnes mayagbarnes deleted the query-param-bind-1 branch January 26, 2026 20:22
@mayagbarnes mayagbarnes added impact:internal PR changes only affect internal code and removed impact:users PR changes affect end users labels Feb 4, 2026
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:internal PR changes only affect internal code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants