Skip to content

[Fix] Restore bound query params in URL with MPA#14374

Merged
sfc-gh-mbarnes merged 5 commits intodevelopfrom
url-resync
Mar 19, 2026
Merged

[Fix] Restore bound query params in URL with MPA#14374
sfc-gh-mbarnes merged 5 commits intodevelopfrom
url-resync

Conversation

@mayagbarnes
Copy link
Copy Markdown
Collaborator

Describe your changes

When a widget uses bind="query-params" in a multi-page app, navigating away and back causes the URL parameter to be lost even though the widget's value persists in session state. This breaks URL shareability — the widget displays the correct value but the URL no longer reflects it.

The root cause is two-fold:

  1. _remove_stale_widgets deletes the widget's value from _old_state during page transitions (stored under the widget ID, which is an element ID that gets filtered as stale), with no mechanism to preserve it for later remount.
  2. register_widget has no code path to restore a missing URL parameter from a persisted non-default widget value.

Changes:

  • Value preservation (_remove_stale_widgets): Before the stale filter runs, save bound keyed widget values under their user keys in _old_state. User keys are not element IDs, so they survive the filter. A durable _query_param_bound_widget_ids set tracks bound intent across page transitions where bindings and metadata may already be cleaned up.
  • URL sync (register_widget): After value resolution, restore the URL parameter when bind="query-params" is active, the param is missing, the value is non-default, and no programmatic st.session_state set occurred this run. Default-value collapsing is preserved (defaults never appear in URL).
  • Bool normalization (_set_corrected_value): Write true/false instead of True/False for bool query params to match URL conventions.

GitHub Issue

Closes #14350

Testing Plan

  • Unit Tests (JS and/or Python): ✅
  • E2E Tests: ✅
  • Manual Testing: ✅

@snyk-io
Copy link
Copy Markdown
Contributor

snyk-io bot commented Mar 15, 2026

Snyk checks have passed. No issues have been found so far.

Status Scan Engine 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 Mar 15, 2026

✅ PR preview is ready!

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

@mayagbarnes mayagbarnes added change:bugfix PR contains bug fix implementation impact:users PR changes affect end users labels Mar 15, 2026
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

Fixes a multipage-app regression where widgets bound via bind="query-params" could keep their persisted value in st.session_state after page navigation, but lose the corresponding URL query parameter—breaking URL shareability and consistency.

Changes:

  • Preserve stale bound keyed widget values across MPA page transitions by saving them under user keys during _remove_stale_widgets.
  • Restore missing URL query params in register_widget for bound widgets when a persisted non-default value exists (while keeping default collapsing behavior).
  • Normalize boolean query param serialization to true/false to match common URL conventions.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
lib/streamlit/runtime/state/session_state.py Preserves bound values across stale cleanup and re-syncs missing URL params on remount; tracks bound intent across page transitions.
lib/streamlit/runtime/state/query_params.py Ensures _set_corrected_value serializes bools as lowercase true/false.
lib/tests/streamlit/runtime/state/session_state_test.py Adds unit tests covering preservation + remount URL sync behavior, plus required context mocking.
lib/tests/streamlit/runtime/state/query_params_test.py Extends scalar correction tests to include boolean normalization.
e2e_playwright/multipage_apps/mpa_basics_test.py Adds E2E regression test verifying query param restoration after navigating away and back.

You can also share your feedback on Copilot code review. Take the survey.

@mayagbarnes mayagbarnes added the ai-review If applied to PR or issue will run AI review workflow label Mar 16, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Mar 16, 2026
@github-actions

This comment was marked as duplicate.

@github-actions github-actions bot added the do-not-merge PR is blocked from merging label Mar 16, 2026
@mayagbarnes mayagbarnes removed the do-not-merge PR is blocked from merging label Mar 16, 2026
@mayagbarnes mayagbarnes marked this pull request as ready for review March 16, 2026 17:47
@mayagbarnes mayagbarnes added the ai-review If applied to PR or issue will run AI review workflow label Mar 16, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Mar 16, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR fixes a bug where widget-bound query parameters (bind="query-params") are lost from the URL when navigating between pages in a multi-page app (MPA). The widget's value persists in session state but disappears from the URL after a page transition and return. The fix addresses two root causes and includes a related normalization improvement:

  1. Value preservation during stale cleanup (session_state.py): A durable _query_param_bound_widget_ids set tracks bound intent across page transitions. Before _remove_stale_widgets filters out element IDs, bound keyed widget values are saved under their user keys, which survive the cleanup.
  2. URL sync on remount (session_state.py): After value resolution in register_widget, the URL parameter is restored when the widget has bind="query-params", the param is missing from the URL, the value is non-default, and no programmatic st.session_state set occurred. Default-value collapsing is preserved.
  3. Bool normalization (query_params.py): set_corrected_value writes lowercase true/false instead of Python's True/False for boolean values to match standard URL conventions.
  4. API cleanup (query_params.py): _set_corrected_value renamed to public set_corrected_value, with a backward-compatible alias. New has_param() and discard_param_no_forward_msg() methods added.

Code Quality

All three reviewers agree the code is well-structured, clean, and follows existing Streamlit patterns. Key strengths highlighted across reviews:

  • The _query_param_bound_widget_ids set is a clean, durable solution for tracking bound intent across page transitions where bindings and metadata may be gone (all reviewers).
  • The pruning logic (intersection_update(wid_key_map.keys())) prevents unbounded growth of the tracking set (opus-4.6-thinking).
  • Multiple guard conditions in the URL sync block are well-designed to prevent incorrect syncing: checking non-default, user-key presence in _old_state, param absence, and no programmatic set (all reviewers).
  • Comments clearly explain the "why" behind each change, which is valuable given the complexity of MPA state management (gemini-3.1-pro, opus-4.6-thinking).
  • The discard_param_no_forward_msg method correctly avoids unnecessary forward messages for backend-only cache cleanup (opus-4.6-thinking).

Minor observations (non-blocking):

  • The @patch added to the existing test_remove_stale_widgets may be unnecessary since DeltaGeneratorTestCase already provides a real ScriptRunContext with fragment_ids_this_run=None. Harmless but slightly inconsistent with existing test style (opus-4.6-thinking).
  • The _set_corrected_value backward-compat alias has no remaining callers in the codebase. Consider adding a # TODO: remove in a future release comment or simply removing it (opus-4.6-thinking).

Test Coverage

All three reviewers agree test coverage is thorough and well-organized:

  • Unit tests (session_state_test.py): RemoveStaleWidgetsPreservationTest (value preservation for bound widgets, non-preservation for unbound, pruning of unmapped IDs), RegisterWidgetUrlSyncTest (URL sync on remount, skip when param exists, skip for default values, removal of stale defaults, skip on programmatic set), and ConditionalRemountBoundBehaviorTest (integration-style bound vs. unbound remount comparison).
  • Unit tests (query_params_test.py): Tests for has_param, discard_param_no_forward_msg, and bool normalization in set_corrected_value.
  • E2E test (mpa_basics_test.py): test_bound_widget_query_param_restored_after_page_switch — full round-trip scenario (set param, navigate away, navigate back, verify param restored) with a negative check verifying default-value collapsing remains intact.

Backwards Compatibility

All three reviewers agree there are no breaking changes:

  • _set_corrected_value preserved as a backward-compatible alias.
  • _query_param_bound_widget_ids is a new internal field with no public API exposure.
  • Bool normalization (Truetrue) is non-breaking because parse_url_param already handles case-insensitive boolean parsing (val.lower()).
  • Existing widget behavior (unbound widgets, fragment runs) is unaffected — the new logic only activates for bind="query-params" widgets.

Security & Risk

All three reviewers agree there are no security concerns. No changes to auth, cookies, CSRF/XSRF, WebSocket handshake, headers, asset serving, external network calls, file system operations, or eval/exec usage. The main risk is behavioral regression in URL/session synchronization logic, which is substantially mitigated by the added tests.

External test recommendation

  • Recommend external_test: No (2-1 consensus)
  • Triggered categories: None (majority view)
  • Evidence:
    • lib/streamlit/runtime/state/session_state.py: Internal session state management changes only; no routing, WebSocket, or embedding changes.
    • lib/streamlit/runtime/state/query_params.py: Query parameter dict operations with no impact on URL handling at the HTTP/server layer.
  • Confidence: High

Reviewer disagreement: gpt-5.3-codex-high recommended external testing (medium confidence) citing "routing and URL behavior" as a triggered category, suggesting validation under reverse-proxy and iframe-embedded modes. gemini-3.1-pro and opus-4.6-thinking both assessed no external test needed (high confidence), reasoning that query parameter behavior is entirely within the Streamlit session/app boundary and doesn't interact with proxy, iframe, or cross-origin layers. The consolidation sides with the majority: the changes are to internal Python session state management and don't alter HTTP-layer routing, URL rewriting, or embedding behavior. However, gpt-5.3's suggestion to add an external test as a follow-up hardening step is reasonable and could be considered for future work.

Accessibility

All three reviewers agree: No frontend changes are included in this PR. All changes are to Python backend code and tests. No accessibility impact.

Recommendations

  1. (Minor, non-blocking) Consider removing the unnecessary @patch on the existing test_remove_stale_widgets test or adding a brief comment explaining why the override is preferred, for consistency with the pre-existing test style (opus-4.6-thinking).
  2. (Minor, non-blocking) The _set_corrected_value backward-compat alias has no remaining callers. Consider adding a # TODO: remove in a future release comment or removing it in a future cleanup pass (opus-4.6-thinking).
  3. (Follow-up) Consider adding external test coverage for multipage bound query-param restore flow under proxy/embed environments as a future hardening step (gpt-5.3-codex-high).

Verdict

APPROVED: All three reviewers unanimously approved this PR. The fix is well-crafted, addressing the root causes of the MPA query-param loss issue with a multi-layered approach (value preservation + URL sync + default collapsing). Test coverage is comprehensive across unit and E2E layers. No backwards-compatibility, security, or accessibility concerns. Minor nits are non-blocking.


This is a consolidated AI review by opus-4.6-thinking. It synthesizes reviews from gemini-3.1-pro, gpt-5.3-codex-high, and opus-4.6-thinking.


📋 Review by `gemini-3.1-pro`

Summary

This PR fixes an issue where bound query parameters (bind="query-params") are lost from the URL when navigating between pages in a multi-page app (MPA). It ensures that the widget's value is preserved in the session state across page transitions and properly restored to the URL when the widget is remounted. Additionally, it normalizes boolean query parameters to use lowercase true/false instead of True/False to better align with standard URL conventions.

Code Quality

The code changes are well-structured, clean, and well-documented.

  • In lib/streamlit/runtime/state/session_state.py, the logic to preserve bound widget values under their user keys before _remove_stale_widgets filters them out is a clever and robust solution.
  • The addition of _query_param_bound_widget_ids to track bound-intent across page transitions is implemented safely with proper cleanup in clear() and pruning in _remove_stale_widgets().
  • The URL sync logic in register_widget is carefully guarded to only trigger for values explicitly preserved during remounts, avoiding conflicts with programmatic session state updates.

Test Coverage

The changes are excellently tested.

  • E2E Tests: A new test test_bound_widget_query_param_restored_after_page_switch in e2e_playwright/multipage_apps/mpa_basics_test.py thoroughly verifies the core issue (navigating away and back restores the URL param) and also checks that default-value collapsing remains intact.
  • Unit Tests: Extensive unit tests have been added in lib/tests/streamlit/runtime/state/session_state_test.py (e.g., PreserveBoundWidgetValuesTest, RegisterWidgetUrlSyncTest, ConditionalRemountBoundBehaviorTest) to cover the new logic in _remove_stale_widgets and register_widget. query_params_test.py is also updated.

Backwards Compatibility

The PR introduces a slight behavioral change for boolean query parameters:

  • Before: Bound boolean widgets (like st.checkbox or st.toggle) would write True or False to the URL.
  • After: They will write true or false.
    This is a positive change that aligns with standard web conventions. However, if any users were manually reading st.query_params.get("my_checkbox") and strictly comparing it to "True", their code might need a minor update. Given that bind="query-params" is a very recent feature, this is an acceptable and necessary correction.

Security & Risk

No security concerns or regression risks identified. The changes are confined to state management and URL synchronization for bound widgets.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • lib/streamlit/runtime/state/query_params.py: Changes only affect how query parameters are synced to the URL string (e.g., lowercasing booleans).
    • lib/streamlit/runtime/state/session_state.py: Changes only affect internal state preservation (_old_state) during MPA page transitions and re-syncing to the URL.
  • Suggested external_test focus areas: N/A
  • Confidence: High
  • Assumptions and gaps: The changes do not touch core routing, auth, embedding boundaries, or asset serving. They only affect the query string of the frontend URL, which is already handled by existing mechanisms.

Accessibility

No frontend changes were made in this PR, so accessibility is not impacted.

Recommendations

The PR is solid and ready to be merged. Great job on the detailed comments explaining the "why" behind the state management logic!

Verdict

APPROVED: The changes elegantly fix the MPA query param bug with comprehensive test coverage and no apparent risks.


This is an automated AI review by gemini-3.1-pro. Please verify the feedback and use your judgment.

📋 Review by `gpt-5.3-codex-high`

Summary

This PR fixes a real multipage regression where query-param-bound widget values persist in session state but disappear from the URL after navigating away and back. The implementation preserves bound stale widget values across page transitions and restores missing non-default URL params during remount, while keeping default-value URL collapsing behavior.

Code Quality

The changes are well-structured and localized:

  • lib/streamlit/runtime/state/session_state.py adds durable bound-intent tracking and remount URL resync logic without widening public API surface.
  • lib/streamlit/runtime/state/query_params.py adds focused helpers (has_param, discard_param_no_forward_msg) and a clearer correction API (set_corrected_value, with private alias retained for compatibility).
  • Tests are organized around behavior-level scenarios (stale cleanup preservation, remount sync, programmatic session-state exceptions), which makes intent and regression coverage clear.

No blocking code-quality issues identified.

Test Coverage

Coverage is strong for this scope:

  • Python unit tests in lib/tests/streamlit/runtime/state/session_state_test.py exercise the key new edge cases: stale cleanup ordering, remount restoration, default collapse, and exclusion of programmatic session-state sets.
  • Python unit tests in lib/tests/streamlit/runtime/state/query_params_test.py validate helper semantics and bool URL normalization.
  • E2E coverage in e2e_playwright/multipage_apps/mpa_basics_test.py now verifies round-trip multipage restore behavior plus a negative check for default-value collapse.

Given the feature scope, unit + e2e coverage is adequate.

Backwards Compatibility

Backwards compatibility looks good:

  • No public API removals.
  • Internal _set_corrected_value behavior is preserved through a compatibility alias.
  • URL bool formatting now normalizes to lowercase (true/false) for corrected values; this is standards-aligned and should be non-breaking for consumers.

Security & Risk

No direct security issues found in this diff.

  • No changes to auth, cookies, CSRF/XSRF, websocket handshake, headers, asset serving, or external network calls.
  • No unsafe dynamic code execution patterns introduced.
  • Main risk is behavioral regression in URL/session synchronization logic; added tests substantially reduce that risk.

External test recommendation

  • Recommend external_test: Yes
  • Triggered categories: 1. Routing and URL behavior
  • Evidence:
    • lib/streamlit/runtime/state/session_state.py: adds URL restore/removal behavior during widget registration and stale cleanup for multipage transitions.
    • lib/streamlit/runtime/state/query_params.py: modifies backend query-param cache/update semantics that affect URL synchronization behavior.
    • e2e_playwright/multipage_apps/mpa_basics_test.py: new multipage URL restoration scenario indicates user-visible URL behavior changed.
  • Suggested external_test focus areas:
    • Validate bound query-param restore across multipage navigation when app is externally hosted behind a non-root base path/reverse proxy.
    • Validate the same flow in iframe-embedded mode to ensure URL updates and default-value collapse remain consistent.
  • Confidence: Medium
  • Assumptions and gaps: Assessment is based on static diff review only (no runtime execution in external modes), and current coverage does not include @pytest.mark.external_test for this scenario.

Accessibility

No frontend UI/component changes were introduced in this PR; accessibility impact is minimal and no new a11y issues were identified from the changed files.

Recommendations

  1. Add one focused @pytest.mark.external_test for the multipage bound query-param restore flow (external app and/or embedded iframe mode), since URL behavior changed and could diverge under proxy/embed environments.

Verdict

APPROVED: The fix is well-targeted, regression-tested, and appears safe to merge, with external-mode coverage recommended as a follow-up hardening step.


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

📋 Review by `opus-4.6-thinking`

Summary

This PR fixes a bug where widget-bound query parameters (bind="query-params") are lost in the URL when navigating away from and back to a page in a multi-page app (MPA), even though the widget's value persists in session state. The fix addresses two root causes: (1) _remove_stale_widgets deleting bound widget values during page transitions, and (2) register_widget lacking a code path to restore a missing URL parameter from a persisted non-default value.

Key changes:

  • Value preservation (session_state.py): A durable _query_param_bound_widget_ids set tracks bound intent across page transitions. Before stale cleanup, bound keyed widget values are saved under their user keys (which survive the element-ID filter).
  • URL sync (session_state.py): After value resolution in register_widget, the URL parameter is restored when bind="query-params" is active, the param is missing, the value is non-default, and no programmatic st.session_state set occurred. Default-value collapsing is preserved.
  • Bool normalization (query_params.py): set_corrected_value writes true/false instead of True/False for bool_value types to match URL conventions.
  • API cleanup (query_params.py): _set_corrected_value renamed to set_corrected_value (public), with a backward-compatible alias. New has_param() and discard_param_no_forward_msg() methods added.

Code Quality

The code is well-structured and follows existing Streamlit patterns. Comments clearly explain the "why" behind each change, which is valuable given the complexity of MPA state management.

Strengths:

  • The _query_param_bound_widget_ids set is a clean solution for tracking bound intent across page transitions where bindings and metadata may be gone.
  • The pruning logic (intersection_update(wid_key_map.keys())) prevents unbounded growth of the tracking set.
  • The discard_param_no_forward_msg method correctly avoids unnecessary forward messages for backend-only cache cleanup.
  • Multiple guard conditions in the URL sync block (register_widget lines 1050-1063) are well-designed to prevent incorrect syncing: checking non-default, user-key presence in _old_state, param absence, and no programmatic set.

Minor observations:

  • The @patch added to the existing test_remove_stale_widgets (line 886 in session_state_test.py) is technically unnecessary since DeltaGeneratorTestCase already provides a real ScriptRunContext with fragment_ids_this_run=None. It's harmless but slightly inconsistent with the pre-existing test style for that class.
  • The _set_corrected_value alias at query_params.py:711 is good for backward compatibility. Since no external callers exist in the codebase (only the new public name is used), the alias could be removed in a future cleanup pass.

Test Coverage

Test coverage is thorough and well-organized:

Unit tests (session_state_test.py):

  • RemoveStaleWidgetsPreservationTest: Tests value preservation for bound widgets, non-preservation for unbound widgets (good negative check), and pruning of unmapped IDs.
  • RegisterWidgetUrlSyncTest: Tests URL sync on remount, skip when param exists, skip for default values, removal of stale default params, skip on programmatic set, and skip for compacted programmatic sets. Each test isolates a specific condition branch.
  • ConditionalRemountBoundBehaviorTest: Integration-style tests comparing bound vs. unbound remount behavior end-to-end.

Unit tests (query_params_test.py):

  • Tests for has_param, discard_param_no_forward_msg, and bool normalization in set_corrected_value.

E2E test (mpa_basics_test.py):

  • test_bound_widget_query_param_restored_after_page_switch: Full round-trip scenario (set param → navigate away → navigate back → verify param restored). Includes a negative check verifying default-value collapsing remains intact after round-trip navigation.

The tests follow the AGENTS.md guidelines well: docstrings on all test functions, anti-regression negative checks, appropriate use of expect with timeouts for E2E, and use of existing DeltaGeneratorTestCase infrastructure.

Backwards Compatibility

No breaking changes:

  • _set_corrected_value preserved as a backward-compatible alias.
  • _query_param_bound_widget_ids is a new internal field with no public API exposure.
  • Bool normalization (Truetrue, Falsefalse in URL) is non-breaking because parse_url_param already handles case-insensitive boolean parsing (val.lower()).
  • Existing widget behavior (unbound widgets, fragment runs) is unaffected—the new logic only activates for bind="query-params" widgets.

Security & Risk

No security concerns identified:

  • No new server endpoints, routes, or external dependencies.
  • No changes to authentication, session management, CORS, CSP, or security headers.
  • No file system operations, subprocess calls, or eval/exec usage.
  • The discard_param_no_forward_msg method operates on an internal dict and doesn't expose new attack surface.
  • URL parameter restoration is guarded by multiple conditions (non-default, user-key presence, no existing param, no programmatic set) that prevent unintended URL manipulation.

Accessibility

No frontend changes are included in this PR. All changes are to Python backend code and tests. No accessibility impact.

Recommendations

  1. Consider removing the unnecessary @patch on the existing test_remove_stale_widgets test (session_state_test.py line 886). The DeltaGeneratorTestCase base class already provides a ScriptRunContext with fragment_ids_this_run=None via add_script_run_ctx. The patch works but is inconsistent with the test's pre-existing style and other tests in the same class that don't mock the context. If kept, a brief comment explaining why the override is preferred would be helpful.

  2. Minor nit: The _set_corrected_value backward-compat alias at query_params.py:711 has no remaining callers in the codebase. Consider adding a # TODO: remove in a future release comment or simply removing it if internal-only.

Verdict

APPROVED: This is a well-crafted bug fix with thorough test coverage, clean implementation, and no backwards-compatibility or security concerns. The multi-layered approach (value preservation + URL sync + default collapsing) correctly addresses the root causes of the MPA query-param loss issue.


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

@sfc-gh-mbarnes sfc-gh-mbarnes merged commit c4f3660 into develop Mar 19, 2026
43 checks passed
@sfc-gh-mbarnes sfc-gh-mbarnes deleted the url-resync branch March 19, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:bugfix PR contains bug fix implementation impact:users PR changes affect end users

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Keep query params from bind when switching pages

4 participants