[feature] Sync URL when session_state updates query-param-bound widgets#14744
Conversation
Made-with: Cursor Co-authored-by: lawilby <laura.wilby+oss@snowflake.com>
✅ 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!
|
- Update test_skips_url_sync_when_session_state_set to assert set_corrected_value IS called (matching gh-14670 behavior) - Bump TEXT_INPUT_ELEMENTS from 22 to 23 for new gh14670 text input - Guard gh-14670 e2e section with runtime.exists() for bare execution Made-with: Cursor Co-authored-by: lawilby <laura.wilby+oss@snowflake.com>
|
| Filename | Overview |
|---|---|
| lib/streamlit/runtime/state/session_state.py | Core fix: programmatic session_state sets for bind=query-params widgets now sync the URL via set_corrected_value; reset-to-default uses remove_param (with ForwardMsg) instead of the silent discard. Two minor style redundancies (elif vs else, spurious restored_bound_value flag). |
| lib/streamlit/runtime/state/query_params.py | Refactors number/value coercion into module-level helpers _format_number_for_query_url and _coerce_value_for_query_url (extracted from set_corrected_value), and adds stored_param_matches_corrected_value for idempotent URL-sync checks. Behaviour of set_corrected_value is unchanged. |
| lib/tests/streamlit/runtime/state/session_state_test.py | Adds RegisterWidgetQueryParamProgrammaticSyncTest with 7 focused unit tests covering the new URL-sync path (set, reset-to-default, idempotent skip, UI-driven exclusion, float/bool/array coercion). Updates test_syncs_url_when_session_state_set to assert set_corrected_value is now called. |
| e2e_playwright/st_text_input_test.py | Adds E2E test verifying programmatic session_state updates sync to the browser URL, that reload preserves the value, and that reset-to-default removes the query param. |
| e2e_playwright/st_text_input.py | Adds the gh-14670 fixture: a bind=query-params text_input with buttons to set/reset it via session_state, used by the new E2E test. |
Reviews (1): Last reviewed commit: "fix: resolve CI failures in test asserti..." | Re-trigger Greptile
- Replace `elif widget_value == default_value` with `else` (always true after the preceding `if widget_value != default_value`) - Remove redundant `restored_bound_value = True` in programmatic-set branch (already implied by `is_new_state_value`) Made-with: Cursor Co-authored-by: lawilby <laura.wilby+oss@snowflake.com>
URL seeding via _handle_query_param_binding puts values into _new_session_state, which the programmatic sync branch incorrectly treated as explicit st.session_state assignments. This caused date/time slider params to be re-serialized in the wrong format and empty-override params for multiselect/pills to be dropped. Guard the programmatic sync branches with `not url_value_seeded` so only genuine st.session_state["key"] = value assignments trigger URL updates. Update slider E2E test to expect URL sync for session_state pre-sets per gh-14670. Made-with: Cursor Co-authored-by: lawilby <laura.wilby+oss@snowflake.com>
There was a problem hiding this comment.
Summary
This PR fixes #14670: programmatic st.session_state[key] = value updates for widgets with bind="query-params" now correctly synchronize the browser URL via a page_info_changed ForwardMsg. Previously, such updates changed the widget value internally but left the URL stale, causing the programmatic value to be lost on page reload. Resetting to the widget default now also removes the query parameter from the URL.
Key changes:
session_state.py: Extended theregister_widgetquery-param sync block with two new branches — one for programmatic sets that differ from the current URL, and one for programmatic resets to default that actively remove the param (with a ForwardMsg).query_params.py: Extracted inline formatting logic into module-level helpers (_format_number_for_query_url,_coerce_value_for_query_url) and addedstored_param_matches_corrected_valuefor deduplication to avoid redundant ForwardMsgs.- Tests: 8 new unit tests in
RegisterWidgetQueryParamProgrammaticSyncTest, an updated remount test, and a new E2E test validating the full lifecycle (set → reload persistence → reset to default).
All three reviewers (claude-4.6-opus-high-thinking, gemini-3.1-pro, gpt-5.3-codex-high) completed their reviews successfully.
Code Quality
Unanimous agreement across all reviewers. The code is well-structured, follows existing patterns, and the refactoring of set_corrected_value into reusable helpers is clean and reduces duplication. The branching logic in register_widget is clearly commented with rationale for each path. Helper functions are correctly module-private (_ prefix), type-annotated, and documented.
Test Coverage
Unanimous agreement: coverage is strong and comprehensive.
- Unit tests (8 new methods): Cover programmatic set with URL sync and ForwardMsg, reset-to-default removal, deduplication when URL already matches, UI-driven exclusion from programmatic sync path, and type coercion for
double_value,bool_value, andstring_array_value. The existing remount test was updated to verify the corrected behavior. - E2E test:
test_text_input_query_param_programmatic_session_state_syncs_urlvalidates the full user-facing lifecycle usingexpectauto-wait assertions andexpect_prefixed_markdownfor value verification. - Minor gap (gpt-5.3-codex-high): An optional
double_array_valueprogrammatic equivalence test could further guard URL-coercion edge cases. Non-blocking.
Backwards Compatibility
Unanimous agreement: no backwards compatibility issues. The behavior change is intentional — programmatic session-state updates for query-param-bound widgets now keep the URL in sync. No API signatures change and no previously valid code breaks. This aligns with user expectations (the issue reporter called the old behavior a "blocker").
Security & Risk
Unanimous agreement: no security concerns. No new endpoints, routes, or WebSocket behavior changes. No changes to authentication, CSRF, cookies, or session management. No new dependencies. Values are URL-encoded via urllib.parse.urlencode. The changes are confined to the existing page_info_changed ForwardMsg mechanism.
Regression risk is low: the core logic change is narrowly scoped to the user_key in self._new_session_state condition, which only fires for programmatic session-state updates in the current run.
External test recommendation
- Recommend external_test: No (2-1 majority)
- Agreement: claude-4.6-opus-high-thinking and gpt-5.3-codex-high both assessed no external test needed, citing that changes are confined to internal query-param sync logic within the existing ForwardMsg mechanism with no routing, auth, or WebSocket changes.
- Disagreement: gemini-3.1-pro recommended external tests targeting categories "Routing and URL behavior" and "Embedding and iframe boundary," suggesting verification of
postMessagesync in iframe embeddings. - Resolution: The changes modify backend-to-frontend ForwardMsg flow for
page_info_changed, which is the same channel used by external/embedded apps. No new cross-origin, routing, or embedding behavior is introduced. The existing E2E test validates URL sync in a real browser. The external test recommendation is No, though the iframepostMessagescenario noted by gemini-3.1-pro could be worth verifying in a future infrastructure-focused effort if any embedding-related regressions surface. - Confidence: High
Accessibility
Unanimous agreement: no accessibility concerns. All changes are in Python backend code and test files — no frontend component rendering or interaction semantics were modified.
Recommendations
- (Low priority) Consider renaming
test_programmatic_float_coercion_matches_url_integer_formto something liketest_programmatic_float_scalar_matches_url_string_form— the current name suggests integer-form collapse, but the test actually verifies that scalardouble_valueusesstr()(producing"5.0"). - (Optional follow-up) Add a unit test for
double_array_valueprogrammatic equivalence to further guard URL-coercion edge cases.
Verdict
APPROVED: Well-scoped bug fix with thorough unit and E2E test coverage, clean refactoring, correct behavioral change, and low regression risk. All three reviewers approved unanimously with no blocking issues identified.
Consolidated from reviews by claude-4.6-opus-high-thinking, gemini-3.1-pro, gpt-5.3-codex-high. Consolidation performed by claude-4.6-opus-high-thinking.
This review also includes 2 inline comment(s) on specific code lines.
Made-with: Cursor Co-authored-by: lawilby <laura.wilby+oss@snowflake.com>
- Rename gh14670 references in E2E tests to descriptive names (bound_text_ss, set_bound_text_ss_btn, etc.) instead of issue numbers - Remove issue number from test docstring - Rename test_programmatic_float_coercion_matches_url_integer_form to test_programmatic_float_scalar_matches_url_string_form per nitpick Made-with: Cursor Co-authored-by: lawilby <laura.wilby+oss@snowflake.com>
|
Addressed all review comments in e46a3e9:
|
Flatten multi-line button calls to single lines per ruff formatter. Made-with: Cursor Co-authored-by: lawilby <laura.wilby+oss@snowflake.com>
Address reviewer nit from @mayagbarnes — reference format consistency. Made-with: Cursor Co-authored-by: lawilby <laura.wilby+oss@snowflake.com>
Keep only the implementation comment reference in session_state.py; the test names and docstrings are self-descriptive. Made-with: Cursor Co-authored-by: lawilby <laura.wilby+oss@snowflake.com>
mayagbarnes
left a comment
There was a problem hiding this comment.
Just the logic change/unit tests suggestions above, other LGTM!
Address @mayagbarnes review: add _old_state check so the programmatic sync branch only fires after the widget has been registered at least once, preventing app-initialization values from polluting fresh URLs. - Add (widget_id in _old_state or user_key in _old_state) guard to both the programmatic-set and reset-to-default branches - Revert slider E2E test to expect no URL sync on initial pre-set - Add test_initial_load_preseeding_does_not_sync_url - Add test_url_seeded_initial_load_does_not_trigger_programmatic_sync Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: lawilby <laura.wilby+oss@snowflake.com>
|
Thank you very much for tackling this! ❤️ As I said when I filed #14670, I was thrilled by the new Is there anything I can do to help? |
Resolved trivial concatenation conflicts in e2e_playwright/st_text_input.py and e2e_playwright/st_text_input_test.py — the PR's bound_text_ss session-state sync harness/test and develop's setvalue_test_input element-hash-memo regression test live side-by-side at the end of each file. Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: lawilby <laura.wilby+oss@snowflake.com>
Both this PR and develop's #15008 independently added a new st.text_input to the harness and each bumped TEXT_INPUT_ELEMENTS from 22 to 23. After the merge the harness has two extra text inputs (bound_text_ss and setvalue_test_input) but the constant was only bumped once. Fix by bumping to 24 to match the actual rendered count. Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: lawilby <laura.wilby+oss@snowflake.com>
|
@JosephMarinier thanks for the nudge, this PR was ready to merge so I've merged it. We should have a new release in ~2 weeks, but also you can use the nightly to use the feature earlier! |
…ts (#14744) ## Describe your changes Programmatic `st.session_state[key] = value` for widgets with `bind="query-params"` now updates the backend query string and emits `page_info_changed`, so the browser URL matches the widget and reload preserves the value. Resets to the widget default use `remove_param` (with ForwardMsg) when the change came from session state in the current run, instead of silently discarding backend state only. Comparison uses the same normalization as `set_corrected_value` (extracted to `_coerce_value_for_query_url`) so reruns that set session state to the URL-equivalent value do not spam ForwardMsgs. ## Screenshot or video (only for visual changes) N/A (URL bar behavior; covered by E2E). ## GitHub Issue Link (if applicable) Implements #14670 ## Testing Plan - [x] Unit Tests (JS and/or Python) - [x] E2E Tests - [ ] Any manual testing needed? Python: `RegisterWidgetQueryParamProgrammaticSyncTest` in `session_state_test.py`. E2E: `test_text_input_query_param_programmatic_session_state_syncs_url` in `st_text_input_test.py`. **Contribution License Agreement** By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license. --------- Co-authored-by: Laura Wilby <laura.wilby@snowflake.com> Co-authored-by: lawilby <laura.wilby+oss@snowflake.com>
…ts (streamlit#14744) ## Describe your changes Programmatic `st.session_state[key] = value` for widgets with `bind="query-params"` now updates the backend query string and emits `page_info_changed`, so the browser URL matches the widget and reload preserves the value. Resets to the widget default use `remove_param` (with ForwardMsg) when the change came from session state in the current run, instead of silently discarding backend state only. Comparison uses the same normalization as `set_corrected_value` (extracted to `_coerce_value_for_query_url`) so reruns that set session state to the URL-equivalent value do not spam ForwardMsgs. ## Screenshot or video (only for visual changes) N/A (URL bar behavior; covered by E2E). ## GitHub Issue Link (if applicable) Implements streamlit#14670 ## Testing Plan - [x] Unit Tests (JS and/or Python) - [x] E2E Tests - [ ] Any manual testing needed? Python: `RegisterWidgetQueryParamProgrammaticSyncTest` in `session_state_test.py`. E2E: `test_text_input_query_param_programmatic_session_state_syncs_url` in `st_text_input_test.py`. **Contribution License Agreement** By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license. --------- Co-authored-by: Laura Wilby <laura.wilby@snowflake.com> Co-authored-by: lawilby <laura.wilby+oss@snowflake.com>
Describe your changes
Programmatic
st.session_state[key] = valuefor widgets withbind="query-params"now updates the backend query string and emitspage_info_changed, so the browser URL matches the widget and reload preserves the value. Resets to the widget default useremove_param(with ForwardMsg) when the change came from session state in the current run, instead of silently discarding backend state only.Comparison uses the same normalization as
set_corrected_value(extracted to_coerce_value_for_query_url) so reruns that set session state to the URL-equivalent value do not spam ForwardMsgs.Screenshot or video (only for visual changes)
N/A (URL bar behavior; covered by E2E).
GitHub Issue Link (if applicable)
Implements #14670
Testing Plan
Python:
RegisterWidgetQueryParamProgrammaticSyncTestinsession_state_test.py. E2E:test_text_input_query_param_programmatic_session_state_syncs_urlinst_text_input_test.py.Contribution License Agreement
By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.