Skip to content

Bind widgets to query params - st.number_input#13917

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

Bind widgets to query params - st.number_input#13917
mayagbarnes merged 5 commits intodevelopfrom
query-number

Conversation

@mayagbarnes
Copy link
Copy Markdown
Collaborator

@mayagbarnes mayagbarnes commented Feb 11, 2026

Describe your changes

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

Key changes:

  • Added bind="query-params" support to st.number_input
  • Invalid (out of bounds, non-numeric) URL values are caught during deserialization and the query param is removed, falling back to the widget default.
  • Clearability is tied to value=None: when the widget is registered with no default value, an empty URL param (?key=) clears the widget to None. When value is set, empty URL params are ignored.

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 11, 2026
@snyk-io
Copy link
Copy Markdown
Contributor

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

✅ PR preview is ready!

Name Link
📦 Wheel file https://core-previews.s3-us-west-2.amazonaws.com/pr-13917/streamlit-1.54.0-py3-none-any.whl
📦 @streamlit/component-v2-lib Download from artifacts
🕹️ Preview app pr-13917.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.number_input, enabling two-way synchronization between the widget's value and URL query parameters. When bind="query-params" is specified with a key, the widget's value is synced with the URL—changing the widget updates the URL, and loading the page with a query parameter initializes the widget from it. Values from the URL are automatically clamped to the specified min_value and max_value constraints.

Changes:

  • Added bind parameter to st.number_input API with query parameter support
  • Implemented URL value clamping in NumberInputSerde.deserialize for out-of-range values
  • Added comprehensive test coverage (Python unit tests, TypeScript unit tests, and E2E tests)

Reviewed changes

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

Show a summary per file
File Description
proto/streamlit/proto/NumberInput.proto Added optional query_param_key field to protobuf message
lib/streamlit/elements/widgets/number_input.py Implemented bind parameter, updated NumberInputSerde to clamp values to min/max bounds, added documentation
lib/tests/streamlit/elements/number_input_test.py Added unit tests for bind parameter validation and serde clamping logic
lib/tests/streamlit/typing/number_input_types.py Added type tests for bind parameter
frontend/lib/src/components/widgets/NumberInput/NumberInput.tsx Configured query param binding in useBasicWidgetState hook
frontend/lib/src/components/widgets/NumberInput/NumberInput.test.tsx Added unit tests for query param binding registration/unregistration
e2e_playwright/st_number_input.py Added test widgets with query param bindings (int, float, clamped)
e2e_playwright/st_number_input_test.py Added E2E tests for seeding, URL updates, clamping, and invalid values

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

Summary

This PR adds bind="query-params" support to st.number_input, enabling two-way sync between the widget's value and URL query parameters. The implementation touches the full stack:

  • Protobuf: Adds an optional query_param_key field (tag 25) to NumberInput.proto.
  • Python backend: Adds the bind parameter to all overloads and the implementation of number_input, passes it to register_widget, and adds clamping logic in NumberInputSerde.deserialize for out-of-range URL-seeded values.
  • Frontend (React): Passes queryParamBinding config to the existing useBasicWidgetState hook.
  • Tests: Python unit tests, frontend unit tests, typing tests, and E2E Playwright tests are all added.

Code Quality

The code is well-structured and follows established patterns closely. The implementation mirrors how other widgets (e.g., Checkbox, ColorPicker) implement query param binding through useBasicWidgetState.

Strengths:

  • Good documentation in the docstring for the new bind parameter (lines 388–395 of number_input.py).
  • Thoughtful comments explaining the distinction between serde-level clamping and the existing bounds-reset logic (lines 679–682).
  • Clean use of cast with # type: ignore[redundant-cast] for type checker compatibility (lines 659–660).
  • The NumberInputSerde dataclass extension with min_value and max_value is a clean way to incorporate clamping.

Minor observations:

  1. In NumberInputSerde.deserialize (line 92 of number_input.py), the clamping val = max(self.min_value, min(self.max_value, val)) always runs when val is not None. For the case where no explicit min/max is provided by the user, the defaults are JSNumber.MIN_SAFE_INTEGER / JSNumber.MAX_SAFE_INTEGER (for ints) or JSNumber.MIN_NEGATIVE_VALUE / JSNumber.MAX_VALUE (for floats). This means clamping is always a no-op for in-range values, which is correct. The clamping is unconditional regardless of whether bind is used, but since it's a no-op for normal values, this is harmless and actually adds a safety net.

  2. The frontend change is minimal and idiomatic — just 7 lines adding the queryParamBinding property to the useBasicWidgetState options object (lines 147–153 of NumberInput.tsx). This follows the exact same pattern as Checkbox.tsx.

Test Coverage

Test coverage is thorough and well-organized:

Python unit tests (number_input_test.py):

  • Tests bind="query-params" sets query_param_key in the proto.
  • Tests that bind="query-params" without a key raises an exception.
  • Tests that no bind doesn't set the key.
  • Tests invalid bind values raise StreamlitInvalidBindValueError.
  • Tests with int, float, and min/max value combinations.
  • Parameterized tests for serde clamping behavior (above max, below min, in range, None).

Frontend unit tests (NumberInput.test.tsx):

  • Tests register/unregister lifecycle for query param binding.
  • Tests that binding is not registered when queryParamKey is absent.
  • Tests with float default values.

Typing tests (number_input_types.py):

  • Verifies return types are preserved when bind is used with various type combinations.

E2E tests (st_number_input_test.py):

  • Seeding int from URL.
  • Seeding float from URL.
  • URL updates when widget value changes.
  • Default override and param removal when value returns to default.
  • Clamping to min/max for out-of-range URL values.
  • Invalid (non-numeric) URL values are handled gracefully.

The E2E tests follow best practices well: using expect for auto-wait assertions, key-based locators (get_element_by_key), page/app_port fixtures for URL-specific tests vs app fixture for interaction tests, and descriptive test names.

Backwards Compatibility

This change is fully backwards compatible:

  • The bind parameter defaults to None, so existing calls to st.number_input are unaffected.
  • The protobuf field query_param_key (tag 25) is optional string, meaning older frontends will simply ignore it, and newer frontends handle its absence gracefully (the conditional element.queryParamKey ? ... : undefined).
  • The serde clamping is always a no-op for values already within bounds, so there's no behavioral change for existing widgets without bind.
  • All existing overload signatures are preserved; bind is added as a keyword-only argument.

Security & Risk

  • No security concerns identified. The query param binding mechanism reuses the existing, well-tested register_widget infrastructure with bind and clearable parameters. Invalid URL values (non-numeric strings) are handled gracefully — the widget falls back to its default and clears the invalid param from the URL.
  • Low regression risk. The only change to existing code paths is the addition of min_value/max_value fields to NumberInputSerde and unconditional clamping in deserialize. Since clamping against the JS number bounds is a no-op for normal values, this is safe.

Accessibility

No accessibility concerns. The frontend change is purely in the data/state layer (passing queryParamBinding to the hook). No DOM structure, ARIA attributes, or keyboard interaction patterns are modified.

Recommendations

  1. Consider adding a negative assertion to E2E tests: For test_number_input_query_param_seeding_int, consider also asserting that the URL does not contain unexpected extra query parameters (e.g., expect(page).not_to_have_url(re.compile(r"bound_float="))) to guard against cross-widget pollution. This would align with the E2E best practice of "at least one 'must NOT happen' check per scenario." However, this is a minor stylistic point and not a blocker.

  2. Consider testing value=None with query param seeding: The E2E app defines bound_int with value=None, and test_number_input_query_param_invalid_value tests the invalid case. It might be valuable to also verify that when bound_int is seeded with a valid integer via URL, the widget correctly transitions from None default to the seeded value and the type is int. This is partially covered by test_number_input_query_param_seeding_int but an explicit check that the returned Python type is int (not float) would be useful since value=None defaults to float type — actually, looking more carefully, bound_int uses value=None with no other type hints, so it defaults to float. The test asserts "42" in the markdown output, which would work for both 42 and 42.0. This is fine since the E2E test is checking user-visible behavior.

Verdict

APPROVED: Clean, well-tested implementation that follows established patterns for query param binding, is fully backwards compatible, and includes comprehensive test coverage across all layers.


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

@mayagbarnes mayagbarnes marked this pull request as ready for review February 12, 2026 04:48
Comment on lines +82 to +92
if val is not None:
if self.data_type == NumberInputProto.INT:
val = int(val)
# Clamp to [min_value, max_value]. Primarily needed for
# out-of-range values seeded from URL query params; a no-op
# for frontend values since the UI already enforces bounds.
# Note: This only runs on *serialized* values (URL seeding,
# fresh frontend submissions). Already-deserialized values
# from previous runs are handled by the bounds-reset check
# in _number_input (see "Validate the current value" block).
val = max(self.min_value, min(self.max_value, val))
Copy link
Copy Markdown
Collaborator

@lukasmasuch lukasmasuch Feb 12, 2026

Choose a reason for hiding this comment

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

We already have a similar mechanism in number_input to handle out-of-range values e.g., when users set it via st.session_state or when min / max dynamically change when key is set. In those cases, the session_state, return value and frontend value just gets reset to the default. E.g. you can try it here:

import streamlit as st

if st.button("Set out of range value"):
    st.session_state.number_input = 101

if st.button("Set valid value"):
    st.session_state.number_input = 10


st.number_input("Number input", value=5, min_value=0, max_value=100, key="number_input")
st.write(st.session_state)

This might work out of the box here as well, not sure. But it might be good to double-check whether you want a different behaviour when set via query parameters (cc @jrieke).

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.

Ooh interesting, I didn't know we have that behavior. Is that behavior specific to number input or also for other widgets, e.g. st.date_input? Agree that it would make sense to unify – either by clamping to the min/max or resetting to default in all cases. IMO clamping to min/max feels a bit more intuitive but I think I'd be fine with both. Do you two have opinions? And which one would be easier to implement?

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.

Its the default behaviour for all similar cases with widgets, e.g. also if the selectbox options list changes and the currently selected option isn't part of the new options list anymore. Resetting to default is something that can be applied consistently in all situations while something like clamping is specific to the type of parameter.

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.

K yeah that makes sense. Hm yeah then maybe easier to just go with resetting to default, wdyt @mayagbarnes? I mean in reality I think this case will happen very very rarely anyway...

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.

I did like the clamping over resetting to default, especially since we remove default from the URL (so ?num=150 with max 100 → URL cleared, which can be confusing because user may think its not working vs that its an invalid entry) but there is value to consistency and Lukas raises a good point in that clamping for number_input (and likely numeric sliders & date/time widgets) would not be consistent across widgets.
Considering its probably a very rare case anyway, perhaps going with revert to default is best.

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 👍

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

- Added `bind="query-params"` support to `st.number_input`
- Invalid (out of bounds, non-numeric) URL values are caught during deserialization and the query param is removed, falling back to the widget default.
- Clearability is tied to `value=None`: when the widget is registered with no default value, an empty URL param (`?key=`) clears the widget to `None`. When `value` is set, empty URL params are ignored.
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.

4 participants