Bind widgets to query params - Part 1#13681
Conversation
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
✅ PR preview is ready!
|
This stack of pull requests is managed by Graphite. Learn more about stacking. |
📈 Python coverage change detectedThe Python unit test coverage has increased by 0.0640%
🎉 Great job on improving test coverage! Coverage by files
|
42a5dd2 to
0e87831
Compare
0e87831 to
a094773
Compare
There was a problem hiding this comment.
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 supportbind="query-params"with key validation. - Enhance
QueryParamsandSessionStateto 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 updatedpage_info_changed.query_string, but the subsequentctx.reset(...)call still passes the originalrerun_data.query_string. This meansQueryParams.set_initial_query_params(called insideScriptRunContext.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_paramsand removed from the URL. To keep URL seeding consistent with the filtered query params,resetshould receive the filtered query string (for example, the one stored onctxafter_send_query_param_msg) so that_initial_query_paramsand_query_paramsstay 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,
📉 Frontend coverage change detectedThe frontend unit test (vitest) coverage has decreased by 0.0000%
✅ Coverage change is within normal range. |
|
@cursor review |
There was a problem hiding this comment.
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.
SummaryAdds 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 QualityThere 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 # 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 CoverageGood unit coverage for parsing, binding lifecycle, and seeding/auto-correction behaviors in Backwards CompatibilityThe changes are additive and gated behind the new Security & RiskPrimary 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
VerdictCHANGES 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 |
| 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. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
No we should be able to remove the following pieces once conversion complete:
formatted_optionsparam fromregister_widget()(here)formatted_optionsfield fromWidgetMetadata(noted below)- The
string_option_typesauto-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) |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fair enough, will explore this a bit more
lukasmasuch
left a comment
There was a problem hiding this comment.
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
pillsandsegmented_control: #13684 - Will probably open a PR for
select_sliderby tomorrow.
But not sure what's the best merge order to do that.

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.
embed/embed_optionsreserved paramsbind_widget()/unbind_widget()to track widget-to-param relationshipsparse_url_param()handles all widget value types (bool, int, float, string, arrays)get_initial_value()retrieves URL values to seed session state on page load_set_corrected_value()updates the URL when widgets clamp/validate valuespopulate_from_query_string()handles page transitionsremove_stale_bindings()removes URL params when conditional widgets unmountTesting Plan