Skip to content

Bind widgets to query params - st.multiselect#13951

Merged
mayagbarnes merged 5 commits intodevelopfrom
query-multiselect
Feb 16, 2026
Merged

Bind widgets to query params - st.multiselect#13951
mayagbarnes merged 5 commits intodevelopfrom
query-multiselect

Conversation

@mayagbarnes
Copy link
Copy Markdown
Collaborator

Describe your changes

Adds the bind parameter to st.multiselect to enable two-way sync between widget values and URL query parameters.

Key changes:

  • Added bind="query-params" support to st.multiselect using repeated params (?tags=Red&tags=Blue)
  • Implement robust URL sanitization for array-valued widgets: invalid option filtering, duplicate deduplication, and max_selections truncation — all with automatic URL correction
  • When accept_new_options=True, any URL string accepted (no filtering)
  • Multiselect is always clearable (with ?foo=)

Testing Plan

  • JS & Python Unit Tests: ✅ Added
  • E2E Tests: ✅ Added
  • Manual Testing: ✅

@mayagbarnes mayagbarnes added security-assessment-completed change:feature PR contains new feature or enhancement implementation impact:users PR changes affect end users labels Feb 15, 2026
@snyk-io
Copy link
Copy Markdown
Contributor

snyk-io bot commented Feb 15, 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 Feb 15, 2026

✅ PR preview is ready!

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

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 adds query parameter binding support to st.multiselect, enabling two-way synchronization between multiselect widget values and URL query parameters. The implementation follows established patterns from other bound widgets (checkbox, radio, selectbox, text_input) and introduces robust URL sanitization for array-valued widgets.

Changes:

  • Added bind="query-params" parameter to st.multiselect that uses repeated URL params (e.g., ?tags=Red&tags=Blue)
  • Implemented URL sanitization pipeline for array-valued widgets: filters invalid options, deduplicates values, and truncates to max_selections limit
  • When accept_new_options=True, any URL string is accepted without validation
  • Multiselect is always clearable (empty URL param ?key= clears to [])

Reviewed changes

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

Show a summary per file
File Description
proto/streamlit/proto/MultiSelect.proto Added optional query_param_key field (15) to enable query param binding
lib/streamlit/runtime/state/common.py Added max_array_length field to WidgetMetadata for array truncation support
lib/streamlit/runtime/state/session_state.py Implemented _sanitize_url_array helper and integrated sanitization into _seed_widget_from_url
lib/streamlit/runtime/state/widgets.py Added max_array_length parameter to register_widget function
lib/streamlit/elements/widgets/multiselect.py Added bind parameter to multiselect, set query_param_key in proto when bound, pass metadata for URL validation
frontend/lib/src/components/widgets/Multiselect/Multiselect.tsx Created queryParamBinding config with urlFormat: "repeated" and passed to useBasicWidgetState
lib/tests/streamlit/typing/multiselect_types.py Added type tests for bind parameter with various configurations
lib/tests/streamlit/runtime/state/session_state_test.py Added 13 unit tests covering array sanitization: filtering, deduplication, truncation, and their composition
lib/tests/streamlit/elements/multiselect_test.py Added 6 unit tests for bind parameter validation and proto setup
frontend/lib/src/components/widgets/Multiselect/Multiselect.test.tsx Added 3 frontend unit tests for query param registration/unregistration
e2e_playwright/st_multiselect_test.py Added 13 E2E tests covering URL seeding, updates, validation, filtering, truncation, and edge cases
e2e_playwright/st_multiselect.py Added 5 test widgets with different query param configurations

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

Summary

This PR adds bind="query-params" support to st.multiselect, enabling two-way synchronization between the widget's value and URL query parameters. Multiple selections use repeated URL params (e.g., ?tags=Red&tags=Blue). The implementation includes robust URL sanitization for array-valued widgets: invalid option filtering, duplicate deduplication, and max_selections truncation — all with automatic URL correction. When accept_new_options=True, any URL string is accepted without filtering.

Key changes span the full stack:

  • Proto: New optional string query_param_key = 15 field in MultiSelect.proto
  • Backend: New max_array_length field on WidgetMetadata, new _sanitize_url_array helper in session_state.py, and array sanitization logic in _seed_widget_from_url
  • Frontend: Query param binding config in Multiselect.tsx using urlFormat: "repeated"
  • Tests: Comprehensive Python unit tests, frontend unit tests, E2E tests, and typing tests

Code Quality

The code is well-structured and follows existing patterns established by other query-param-bound widgets (selectbox, radio, checkbox, etc.).

Strengths:

  • The _sanitize_url_array function (session_state.py:73-106) is cleanly separated as a pure helper with clear responsibilities: filter → deduplicate → truncate.
  • The sanitization pipeline order (filter invalid → dedup → truncate) is correct and well-documented in both code comments and tests.
  • The WidgetMetadata extensions (max_array_length at common.py:175-177) are well-documented with clear docstrings explaining their purpose.
  • The bind parameter docstring in multiselect.py:397-407 is thorough and documents edge cases.
  • The pattern of always passing clearable=True, formatted_options, and max_array_length to register_widget regardless of whether bind is set follows the same convention as other widgets (e.g., text_widgets.py).

Minor observations (non-blocking):

  1. In _sanitize_url_array (session_state.py:89), v in valid_options performs O(n) membership testing on a list. For typical multiselect option counts this is negligible, but converting to a set first would be an easy optimization. This is very minor given practical option list sizes.
  2. The return-value logic in _sanitize_url_array (line 106: result if result != parsed else None) relies on list equality comparison (==) to detect changes. This works correctly: when filtering/dedup/truncation produce an identical list, None is returned (no correction needed). When values change, the lists differ and the sanitized result is returned.

Test Coverage

Test coverage is excellent and thorough across all layers:

Python unit tests (multiselect_test.py):

  • 6 tests covering proto field setting, error cases (missing key, invalid bind value), and combinations with format_func and accept_new_options.

Session state unit tests (session_state_test.py):

  • 12 new tests for _seed_widget_from_url covering array sanitization:
    • Novel values with no formatted_options (accept_new_options behavior)
    • Dedup without formatted_options
    • Invalid value filtering, all-invalid clearing, all-valid pass-through
    • max_array_length truncation, within-limit pass-through
    • Composition: filter + truncate, filter + dedup + truncate
    • Truncation resulting in default value → clears URL
    • All-duplicate collapse

Frontend unit tests (Multiselect.test.tsx):

  • 3 tests for query param binding: registration, unregistration on unmount, and no-binding when key not set.

E2E tests (st_multiselect_test.py):

  • 13 comprehensive E2E tests covering: single/multiple value seeding, URL updates on interaction, default override and revert, invalid value filtering, all-invalid clearing, format_func, empty value with/without default, max_selections truncation, accept_new_options, and dedup.
  • Tests properly use page + app_base_url fixtures with build_app_url for URL construction (following e2e_playwright/AGENTS.md guidelines).
  • Tests include negative assertions (e.g., expect(page).not_to_have_url(...)) as recommended.

Typing tests (multiselect_types.py):

  • 4 new assert_type checks confirming bind parameter types work correctly with different option types and accept_new_options.

Backwards Compatibility

No breaking changes. The bind parameter defaults to None in all overloads, preserving existing API behavior. The protobuf field is optional, so:

  • Old frontends won't send or receive it
  • Old backends won't set it
  • The MULTISELECT_COUNT in the e2e test was correctly updated from 21 to 26 to account for the 5 new bound widgets

Security & Risk

  • URL injection: Invalid URL values are properly sanitized — unknown options are filtered out, duplicates are removed, and excess values are truncated. The _sanitize_url_array function handles this defensively.
  • accept_new_options: When enabled, any URL string is accepted (by passing formatted_options=None), which is intentional and matches the widget's existing behavior of accepting user-typed values.
  • max_selections enforcement: URL-seeded values are truncated to respect max_selections, preventing StreamlitSelectionCountExceedsMaxError from being raised by malicious URLs.
  • Empty value handling: Empty URL params (?key=) correctly clear the multiselect to [] since clearable=True.
  • The PR has the security-assessment-completed label applied.

Accessibility

The frontend changes are minimal — only adding the queryParamBinding configuration object in Multiselect.tsx (lines 107-114). No new DOM elements, ARIA attributes, or interactive controls are introduced. The existing accessibility features of the multiselect widget (aria-label, role="combobox", etc.) are preserved unchanged.

Recommendations

  1. Minor optimization (non-blocking): In _sanitize_url_array (session_state.py:89), consider converting valid_options to a set for O(1) membership testing:
    if valid_options is not None:
        valid_set = set(valid_options)
        result = [v for v in result if v in valid_set]
    This is a micro-optimization for typical use but would help if someone has a very large options list.

Verdict

APPROVED: This is a well-implemented feature addition that follows established patterns, includes thorough test coverage across all layers, and handles edge cases (invalid values, duplicates, max_selections, accept_new_options) robustly. The code is clean, well-documented, and backwards compatible.


This is an automated AI review by opus-4.6-thinking.

@mayagbarnes mayagbarnes marked this pull request as ready for review February 16, 2026 18:34
@github-actions
Copy link
Copy Markdown
Contributor

Summary

Adds bind="query-params" support for st.multiselect across backend and frontend, including URL sanitization for array values (filtering, deduplication, truncation), a new proto field for the query param key, and broad unit/e2e coverage.

Code Quality

Implementation follows existing query-parameter binding patterns and keeps sanitization logic centralized. No blocking correctness issues found.

Test Coverage

Added Python unit tests (lib/tests/streamlit/elements/multiselect_test.py, lib/tests/streamlit/runtime/state/session_state_test.py), typing checks, frontend unit tests (frontend/lib/src/components/widgets/Multiselect/Multiselect.test.tsx), and Playwright e2e coverage (e2e_playwright/st_multiselect_test.py). Tests not run in this review.

Backwards Compatibility

bind is optional and defaults to None; the new proto field is optional. Existing multiselect behavior remains unchanged when binding is not used.

Security & Risk

No new security-sensitive surfaces beyond optional URL synchronization. Sanitization and deduplication reduce risk of invalid URL state.

Accessibility

No changes to interaction semantics or labeling patterns; accessibility behavior appears unchanged.

Recommendations

  1. Consider unbinding on the backend when a keyed widget stops using bind="query-params" so URL params are not left permanently marked as bound for active widgets. Cleanup currently only happens when the widget becomes stale, which can surprise apps that toggle binding on/off for a stable key.
        # Handle query param binding
        url_value_seeded = False
        if metadata.bind == "query-params" and user_key is not None:
            url_value_seeded = self._handle_query_param_binding(
                metadata, user_key, widget_id
            )

        if (
            widget_id not in self
            and (user_key is None or user_key not in self)
            and not url_value_seeded
        ):
            # This is the first time the widget is registered, so we save its
            # value in widget state (unless we already seeded from URL).
            deserializer = metadata.deserializer
            initial_widget_value = deepcopy(deserializer(None))
            self._new_widget_state.set_from_value(widget_id, initial_widget_value)

        # Get the current value of the widget for use as its return value.
        # We return a copy, so that reference types can't be accidentally
        # mutated by user code.
        widget_value = cast("T", self[widget_id])
        widget_value = deepcopy(widget_value)

        # widget_value_changed indicates to the caller that the widget's
        # current value is different from what is in the frontend.
        widget_value_changed = user_key is not None and self.is_new_state_value(
            user_key
        )

        return RegisterWidgetResult(widget_value, widget_value_changed)

    def _handle_query_param_binding(
        self, metadata: WidgetMetadata[T], user_key: str, widget_id: str
    ) -> bool:
        """Handle query param binding for a widget.

        Registers the binding, then attempts to seed the widget's value from URL
        based on priority rules:
    def remove_stale_bindings(
        self,
        active_widget_ids: set[str],
        fragment_ids_this_run: list[str] | None = None,
        widget_metadata: dict[str, Any] | None = None,
    ) -> None:
        """Remove bindings and URL params for widgets that are no longer active.

        This cleans up query params for conditional widgets that have been unmounted.
        For fragment runs, widgets outside the running fragment(s) are preserved.

        Note: Page-based cleanup for MPA navigation is handled separately via
        populate_from_query_string() which is called before the script runs.
        """
        stale_widget_ids = []
        for widget_id in self._bindings_by_widget:
            if widget_id in active_widget_ids:
                # Widget is active in this run - keep it
                continue

            # For fragment runs, preserve widgets that aren't part of the running fragments
            if fragment_ids_this_run and widget_metadata:
                metadata = widget_metadata.get(widget_id)
                if metadata and metadata.fragment_id not in fragment_ids_this_run:
                    # Widget belongs to a different fragment or main script - keep it
                    continue

            stale_widget_ids.append(widget_id)

        params_removed = False
        for widget_id in stale_widget_ids:
            binding = self._bindings_by_widget.get(widget_id)
            if binding:
                param_key = binding.param_key
                # Remove the query param from the URL
                if param_key in self._query_params:
                    del self._query_params[param_key]
                    params_removed = True
            self.unbind_widget(widget_id)

        # Send forward message to update frontend URL if we removed any params
        if params_removed:
            self._send_query_param_msg()

Verdict

APPROVED: Solid implementation with thorough tests; only a minor unbinding edge case to consider.


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

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.

LGTM 👍

Consider unbinding on the backend when a keyed widget stops using bind="query-params" so URL params are not left permanently marked as bound for active widgets.

Is this valid? If so, might be a good to add to potential follow-ups.

@mayagbarnes mayagbarnes merged commit 19f0075 into develop Feb 16, 2026
42 checks passed
@mayagbarnes mayagbarnes deleted the query-multiselect branch February 16, 2026 19:37
lukasmasuch pushed a commit that referenced this pull request Feb 20, 2026
Adds the bind parameter to `st.multiselect` to enable two-way sync between widget values and URL query parameters.

**Key changes:**
- Added `bind="query-params"` support to `st.multiselect` using repeated params (`?tags=Red&tags=Blue`)
- Implement robust URL sanitization for array-valued widgets: invalid option filtering, duplicate deduplication, and max_selections truncation — all with automatic URL correction
- When `accept_new_options=True`, any URL string accepted (no filtering)
- Multiselect is always clearable (with `?foo=`)
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

Development

Successfully merging this pull request may close these issues.

3 participants