Skip to content

Bind widgets to query params - Part 3#13683

Closed
mayagbarnes wants to merge 9 commits intoquery-param-bind-2from
query-param-bind-3
Closed

Bind widgets to query params - Part 3#13683
mayagbarnes wants to merge 9 commits intoquery-param-bind-2from
query-param-bind-3

Conversation

@mayagbarnes
Copy link
Copy Markdown
Collaborator

@mayagbarnes mayagbarnes commented Jan 23, 2026

Describe your changes

Bind Widgets to Query Params - Part 3
This PR adds the bind parameter to st.checkbox, st.toggle, and st.color_picker to enable two-way sync between widget values and URL query parameters.

  • URL seeding - Widget initializes from URL on page load
  • URL sync - Clicking widget updates URL in real-time
  • Default cleanup - URL param removed when widget returns to default value
  • Hex validation - Color picker validates/normalizes hex colors from URL (supports both 3-char and 6-char formats, with or without # prefix)
  • Error handling - StreamlitInvalidBindValueError for invalid bind values
  • useQueryParamBinding hook - Extracted reusable hook for registering/unregistering query param bindings

Testing Plan

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

@snyk-io
Copy link
Copy Markdown
Contributor

snyk-io bot commented Jan 23, 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 Jan 23, 2026

✅ PR preview is ready!

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

Copy link
Copy Markdown
Collaborator Author

mayagbarnes commented Jan 23, 2026

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@mayagbarnes mayagbarnes changed the title Translate prototype code - checkbox & toggle Bind widgets to query params - Part 3 Jan 23, 2026
@mayagbarnes mayagbarnes added change:feature PR contains new feature or enhancement implementation impact:users PR changes affect end users security-assessment-completed labels Jan 23, 2026 — with Graphite App
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Jan 23, 2026

📈 Frontend coverage change detected

The frontend unit test (vitest) coverage has increased by 0.0800%

  • Current PR: 86.3300% (13662 lines, 1867 missed)
  • Latest develop: 86.2500% (13540 lines, 1861 missed)

🎉 Great job on improving test coverage!

📊 View detailed coverage comparison

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Jan 23, 2026

📉 Python coverage change detected

The Python unit test coverage has decreased by 0.0033%

  • Current PR: 93.1176% (23175 statements, 1595 missed)
  • Latest develop: 93.1209% (23157 statements, 1593 missed)

✅ Coverage change is within normal range.

Coverage by files
Name Stmts Miss Cover
streamlit/__init__.py 136 0 100%
streamlit/__main__.py 3 3 0%
streamlit/auth_util.py 231 25 89%
streamlit/cli_util.py 39 6 85%
streamlit/column_config.py 3 0 100%
streamlit/commands/__init__.py 0 0 100%
streamlit/commands/echo.py 54 2 96%
streamlit/commands/execution_control.py 70 10 86%
streamlit/commands/logo.py 53 1 98%
streamlit/commands/navigation.py 106 2 98%
streamlit/commands/page_config.py 106 4 96%
streamlit/components/__init__.py 0 0 100%
streamlit/components/lib/__init__.py 0 0 100%
streamlit/components/lib/local_component_registry.py 35 2 94%
streamlit/components/types/__init__.py 0 0 100%
streamlit/components/types/base_component_registry.py 14 0 100%
streamlit/components/types/base_custom_component.py 48 6 88%
streamlit/components/v1/__init__.py 5 0 100%
streamlit/components/v1/component_arrow.py 33 2 94%
streamlit/components/v1/component_registry.py 41 3 93%
streamlit/components/v1/components.py 4 4 0%
streamlit/components/v1/custom_component.py 84 7 92%
streamlit/components/v2/__init__.py 27 0 100%
streamlit/components/v2/bidi_component/__init__.py 4 0 100%
streamlit/components/v2/bidi_component/constants.py 5 0 100%
streamlit/components/v2/bidi_component/main.py 148 17 89%
streamlit/components/v2/bidi_component/serialization.py 81 2 98%
streamlit/components/v2/bidi_component/state.py 13 0 100%
streamlit/components/v2/component_definition_resolver.py 30 0 100%
streamlit/components/v2/component_file_watcher.py 117 9 92%
streamlit/components/v2/component_manager.py 97 13 87%
streamlit/components/v2/component_manifest_handler.py 24 0 100%
streamlit/components/v2/component_path_utils.py 68 5 93%
streamlit/components/v2/component_registry.py 121 8 93%
streamlit/components/v2/get_bidi_component_manager.py 8 1 88%
streamlit/components/v2/manifest_scanner.py 227 25 89%
streamlit/components/v2/presentation.py 84 19 77%
streamlit/components/v2/types.py 8 8 0%
streamlit/config.py 415 12 97%
streamlit/config_option.py 79 3 96%
streamlit/config_util.py 288 7 98%
streamlit/connections/__init__.py 6 0 100%
streamlit/connections/base_connection.py 49 0 100%
streamlit/connections/snowflake_connection.py 98 16 84%
streamlit/connections/snowpark_connection.py 44 3 93%
streamlit/connections/sql_connection.py 56 6 89%
streamlit/connections/util.py 33 0 100%
streamlit/cursor.py 130 1 99%
streamlit/dataframe_util.py 506 47 91%
streamlit/delta_generator.py 250 7 97%
streamlit/delta_generator_singletons.py 74 7 91%
streamlit/deprecation_util.py 66 4 94%
streamlit/development.py 1 0 100%
streamlit/elements/__init__.py 0 0 100%
streamlit/elements/alert.py 60 0 100%
streamlit/elements/arrow.py 203 15 93%
streamlit/elements/balloons.py 10 0 100%
streamlit/elements/bokeh_chart.py 9 0 100%
streamlit/elements/code.py 20 1 95%
streamlit/elements/deck_gl_json_chart.py 104 10 90%
streamlit/elements/dialog_decorator.py 38 0 100%
streamlit/elements/doc_string.py 227 9 96%
streamlit/elements/empty.py 16 4 75%
streamlit/elements/exception.py 101 10 90%
streamlit/elements/form.py 56 2 96%
streamlit/elements/graphviz_chart.py 36 1 97%
streamlit/elements/heading.py 56 0 100%
streamlit/elements/html.py 49 0 100%
streamlit/elements/iframe.py 29 0 100%
streamlit/elements/image.py 32 0 100%
streamlit/elements/json.py 48 6 88%
streamlit/elements/layouts.py 140 3 98%
streamlit/elements/lib/__init__.py 0 0 100%
streamlit/elements/lib/built_in_chart_utils.py 391 26 93%
streamlit/elements/lib/color_util.py 100 4 96%
streamlit/elements/lib/column_config_utils.py 169 1 99%
streamlit/elements/lib/column_types.py 190 4 98%
streamlit/elements/lib/dialog.py 69 1 99%
streamlit/elements/lib/dicttools.py 39 2 95%
streamlit/elements/lib/file_uploader_utils.py 30 0 100%
streamlit/elements/lib/form_utils.py 26 0 100%
streamlit/elements/lib/image_utils.py 176 21 88%
streamlit/elements/lib/js_number.py 28 3 89%
streamlit/elements/lib/layout_utils.py 121 1 99%
streamlit/elements/lib/mutable_status_container.py 73 4 95%
streamlit/elements/lib/options_selector_utils.py 142 2 99%
streamlit/elements/lib/pandas_styler_utils.py 80 2 98%
streamlit/elements/lib/policies.py 56 1 98%
streamlit/elements/lib/shortcut_utils.py 42 2 95%
streamlit/elements/lib/streamlit_plotly_theme.py 48 0 100%
streamlit/elements/lib/subtitle_utils.py 76 5 93%
streamlit/elements/lib/utils.py 76 5 93%
streamlit/elements/map.py 110 1 99%
streamlit/elements/markdown.py 65 2 97%
streamlit/elements/media.py 181 8 96%
streamlit/elements/metric.py 104 0 100%
streamlit/elements/pdf.py 49 2 96%
streamlit/elements/plotly_chart.py 129 6 95%
streamlit/elements/progress.py 36 0 100%
streamlit/elements/pyplot.py 39 2 95%
streamlit/elements/snow.py 10 0 100%
streamlit/elements/space.py 12 0 100%
streamlit/elements/spinner.py 44 3 93%
streamlit/elements/text.py 16 0 100%
streamlit/elements/toast.py 26 0 100%
streamlit/elements/vega_charts.py 238 3 99%
streamlit/elements/widgets/__init__.py 0 0 100%
streamlit/elements/widgets/audio_input.py 68 1 99%
streamlit/elements/widgets/button.py 245 6 98%
streamlit/elements/widgets/button_group.py 171 1 99%
streamlit/elements/widgets/camera_input.py 62 1 98%
streamlit/elements/widgets/chat.py 237 38 84%
streamlit/elements/widgets/checkbox.py 54 0 100%
streamlit/elements/widgets/color_picker.py 70 4 94%
streamlit/elements/widgets/data_editor.py 254 14 94%
streamlit/elements/widgets/file_uploader.py 108 10 91%
streamlit/elements/widgets/multiselect.py 114 5 96%
streamlit/elements/widgets/number_input.py 146 4 97%
streamlit/elements/widgets/radio.py 103 5 95%
streamlit/elements/widgets/select_slider.py 122 2 98%
streamlit/elements/widgets/selectbox.py 97 3 97%
streamlit/elements/widgets/slider.py 241 8 97%
streamlit/elements/widgets/text_widgets.py 130 6 95%
streamlit/elements/widgets/time_widgets.py 425 21 95%
streamlit/elements/write.py 166 20 88%
streamlit/emojis.py 4 0 100%
streamlit/env_util.py 21 3 86%
streamlit/error_util.py 33 2 94%
streamlit/errors.py 187 25 87%
streamlit/external/__init__.py 0 0 100%
streamlit/external/langchain/__init__.py 2 0 100%
streamlit/external/langchain/streamlit_callback_handler.py 141 82 42%
streamlit/file_util.py 84 8 90%
streamlit/git_util.py 100 5 95%
streamlit/logger.py 54 0 100%
streamlit/material_icon_names.py 1 0 100%
streamlit/navigation/__init__.py 0 0 100%
streamlit/navigation/page.py 78 2 97%
streamlit/net_util.py 55 3 95%
streamlit/platform.py 10 1 90%
streamlit/runtime/__init__.py 8 0 100%
streamlit/runtime/app_session.py 456 85 81%
streamlit/runtime/caching/__init__.py 21 0 100%
streamlit/runtime/caching/cache_data_api.py 191 3 98%
streamlit/runtime/caching/cache_errors.py 44 4 91%
streamlit/runtime/caching/cache_resource_api.py 165 1 99%
streamlit/runtime/caching/cache_type.py 11 1 91%
streamlit/runtime/caching/cache_utils.py 176 9 95%
streamlit/runtime/caching/cached_message_replay.py 108 1 99%
streamlit/runtime/caching/hashing.py 310 25 92%
streamlit/runtime/caching/legacy_cache_api.py 14 0 100%
streamlit/runtime/caching/storage/__init__.py 2 0 100%
streamlit/runtime/caching/storage/cache_storage_protocol.py 29 0 100%
streamlit/runtime/caching/storage/dummy_cache_storage.py 21 0 100%
streamlit/runtime/caching/storage/in_memory_cache_storage_wrapper.py 67 1 99%
streamlit/runtime/caching/storage/local_disk_cache_storage.py 86 4 95%
streamlit/runtime/caching/ttl_cleanup_cache.py 28 0 100%
streamlit/runtime/connection_factory.py 96 11 89%
streamlit/runtime/context.py 137 0 100%
streamlit/runtime/context_util.py 18 0 100%
streamlit/runtime/credentials.py 139 4 97%
streamlit/runtime/download_data_util.py 27 0 100%
streamlit/runtime/forward_msg_cache.py 23 2 91%
streamlit/runtime/forward_msg_queue.py 63 4 94%
streamlit/runtime/fragment.py 112 2 98%
streamlit/runtime/media_file_manager.py 110 7 94%
streamlit/runtime/media_file_storage.py 15 0 100%
streamlit/runtime/memory_media_file_storage.py 73 0 100%
streamlit/runtime/memory_session_storage.py 15 0 100%
streamlit/runtime/memory_uploaded_file_manager.py 46 1 98%
streamlit/runtime/metrics_util.py 195 13 93%
streamlit/runtime/pages_manager.py 59 2 97%
streamlit/runtime/runtime.py 253 16 94%
streamlit/runtime/runtime_util.py 30 1 97%
streamlit/runtime/script_data.py 16 0 100%
streamlit/runtime/scriptrunner/__init__.py 5 0 100%
streamlit/runtime/scriptrunner/exec_code.py 49 5 90%
streamlit/runtime/scriptrunner/magic.py 83 1 99%
streamlit/runtime/scriptrunner/magic_funcs.py 10 1 90%
streamlit/runtime/scriptrunner/script_cache.py 27 0 100%
streamlit/runtime/scriptrunner/script_runner.py 235 27 89%
streamlit/runtime/scriptrunner_utils/__init__.py 0 0 100%
streamlit/runtime/scriptrunner_utils/exceptions.py 9 1 89%
streamlit/runtime/scriptrunner_utils/script_requests.py 106 5 95%
streamlit/runtime/scriptrunner_utils/script_run_context.py 118 0 100%
streamlit/runtime/secrets.py 241 25 90%
streamlit/runtime/session_manager.py 71 2 97%
streamlit/runtime/state/__init__.py 7 0 100%
streamlit/runtime/state/common.py 55 1 98%
streamlit/runtime/state/presentation.py 19 4 79%
streamlit/runtime/state/query_params.py 274 6 98%
streamlit/runtime/state/query_params_proxy.py 71 0 100%
streamlit/runtime/state/safe_session_state.py 77 9 88%
streamlit/runtime/state/session_state.py 503 35 93%
streamlit/runtime/state/session_state_proxy.py 62 8 87%
streamlit/runtime/state/widgets.py 21 0 100%
streamlit/runtime/stats.py 132 4 97%
streamlit/runtime/theme_util.py 46 1 98%
streamlit/runtime/uploaded_file_manager.py 39 3 92%
streamlit/runtime/websocket_session_manager.py 116 0 100%
streamlit/source_util.py 36 1 97%
streamlit/starlette.py 2 0 100%
streamlit/string_util.py 93 9 90%
streamlit/temporary_directory.py 18 1 94%
streamlit/testing/__init__.py 0 0 100%
streamlit/testing/v1/__init__.py 2 0 100%
streamlit/testing/v1/app_test.py 242 5 98%
streamlit/testing/v1/element_tree.py 1372 81 94%
streamlit/testing/v1/local_script_runner.py 71 2 97%
streamlit/testing/v1/util.py 17 0 100%
streamlit/time_util.py 28 1 96%
streamlit/type_util.py 148 16 89%
streamlit/url_util.py 39 4 90%
streamlit/user_info.py 105 8 92%
streamlit/util.py 38 1 97%
streamlit/version.py 3 0 100%
streamlit/watcher/__init__.py 3 0 100%
streamlit/watcher/event_based_path_watcher.py 184 25 86%
streamlit/watcher/folder_black_list.py 14 1 93%
streamlit/watcher/local_sources_watcher.py 127 9 93%
streamlit/watcher/path_watcher.py 42 3 93%
streamlit/watcher/polling_path_watcher.py 55 2 96%
streamlit/watcher/util.py 59 1 98%
streamlit/web/__init__.py 0 0 100%
streamlit/web/bootstrap.py 174 21 88%
streamlit/web/cache_storage_manager_config.py 5 0 100%
streamlit/web/cli.py 188 16 91%
streamlit/web/server/__init__.py 5 0 100%
streamlit/web/server/app_discovery.py 104 5 95%
streamlit/web/server/app_static_file_handler.py 29 3 90%
streamlit/web/server/authlib_tornado_integration.py 42 5 88%
streamlit/web/server/bidi_component_request_handler.py 65 8 88%
streamlit/web/server/browser_websocket_handler.py 147 18 88%
streamlit/web/server/component_file_utils.py 24 0 100%
streamlit/web/server/component_request_handler.py 55 4 93%
streamlit/web/server/media_file_handler.py 65 9 86%
streamlit/web/server/oauth_authlib_routes.py 162 33 80%
streamlit/web/server/oidc_mixin.py 46 0 100%
streamlit/web/server/routes.py 90 7 92%
streamlit/web/server/server.py 195 13 93%
streamlit/web/server/server_util.py 68 5 93%
streamlit/web/server/starlette/__init__.py 3 0 100%
streamlit/web/server/starlette/starlette_app.py 146 4 97%
streamlit/web/server/starlette/starlette_app_utils.py 101 7 93%
streamlit/web/server/starlette/starlette_auth_routes.py 233 51 78%
streamlit/web/server/starlette/starlette_gzip_middleware.py 30 0 100%
streamlit/web/server/starlette/starlette_routes.py 346 86 75%
streamlit/web/server/starlette/starlette_server.py 167 7 96%
streamlit/web/server/starlette/starlette_server_config.py 13 0 100%
streamlit/web/server/starlette/starlette_static_routes.py 64 3 95%
streamlit/web/server/starlette/starlette_websocket.py 203 23 89%
streamlit/web/server/stats_request_handler.py 59 5 92%
streamlit/web/server/upload_file_request_handler.py 59 7 88%
streamlit/web/server/websocket_headers.py 19 1 95%
TOTAL 23175 1595 93%

📊 View detailed coverage comparison

@sfc-gh-mbarnes sfc-gh-mbarnes force-pushed the query-param-bind-3 branch 2 times, most recently from 573d177 to c89191b Compare January 23, 2026 21:39
@mayagbarnes mayagbarnes changed the base branch from query-param-bind-2 to graphite-base/13683 January 23, 2026 23:05
@mayagbarnes mayagbarnes marked this pull request as ready for review January 23, 2026 23:07
@mayagbarnes mayagbarnes requested a review from a team as a code owner January 23, 2026 23:07
@mayagbarnes mayagbarnes requested a review from Copilot January 23, 2026 23:08
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

Adds bind="query-params" support for st.checkbox, st.toggle, and st.color_picker, enabling two-way synchronization between widget values and URL query parameters (URL seeding, live URL updates, and default-value cleanup), with shared frontend registration logic.

Changes:

  • Extend Checkbox/Toggle and ColorPicker protobufs with an optional query_param_key field used for query-param binding.
  • Add backend bind validation + wiring to widget protos and registration, including color hex normalization/validation for URL-seeded values.
  • Add a reusable frontend hook (useQueryParamBinding) and unit/e2e tests for the new binding behavior.

Reviewed changes

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

Show a summary per file
File Description
proto/streamlit/proto/ColorPicker.proto Adds query_param_key to ColorPicker proto for URL binding.
proto/streamlit/proto/Checkbox.proto Adds query_param_key to Checkbox proto (used by checkbox + toggle).
lib/tests/streamlit/runtime/state/widgets_test.py Excludes bind from element-id computation inputs.
lib/tests/streamlit/elements/color_picker_test.py Adds unit tests for bind behavior and invalid bind values.
lib/tests/streamlit/elements/checkbox_test.py Adds parameterized unit tests for checkbox/toggle bind behavior.
lib/streamlit/runtime/state/widgets.py Validates bind and enforces key requirement for query-param binding.
lib/streamlit/errors.py Introduces StreamlitInvalidBindValueError.
lib/streamlit/elements/widgets/color_picker.py Adds bind param, sets proto query_param_key, and normalizes/validates URL-seeded hex colors.
lib/streamlit/elements/widgets/checkbox.py Adds bind param and sets proto query_param_key for checkbox/toggle.
frontend/lib/src/hooks/useQueryParamBinding.ts New hook to register/unregister query param bindings with the WidgetStateManager.
frontend/lib/src/hooks/useQueryParamBinding.test.ts Unit tests for the new hook behavior.
frontend/lib/src/components/widgets/ColorPicker/ColorPicker.tsx Registers query param binding for ColorPicker and adds safer default fallbacks.
frontend/lib/src/components/widgets/ColorPicker/ColorPicker.test.tsx Adds unit tests verifying binding registration/unregistration for ColorPicker.
frontend/lib/src/components/widgets/Checkbox/Checkbox.tsx Registers query param binding for checkbox/toggle.
frontend/lib/src/components/widgets/Checkbox/Checkbox.test.tsx Adds unit tests verifying binding registration/unregistration for Checkbox/Toggle.
e2e_playwright/st_toggle_test.py Adds e2e coverage for toggle URL seeding/sync/default cleanup/invalid URL handling.
e2e_playwright/st_toggle.py Adds bound toggle examples to the toggle e2e app.
e2e_playwright/st_color_picker_test.py Adds e2e coverage for color picker URL seeding/sync/default cleanup/invalid URL handling (incl. 3-char hex).
e2e_playwright/st_color_picker.py Adds bound color picker examples to the color picker e2e app.
e2e_playwright/st_checkbox_test.py Adds e2e coverage for checkbox URL seeding/sync/default cleanup/invalid URL handling.
e2e_playwright/st_checkbox.py Adds bound checkbox examples to the checkbox e2e app.
Comments suppressed due to low confidence (1)

lib/streamlit/runtime/state/widgets.py:138

  • For bind='query-params', an empty-string key (e.g. key="") will currently pass validation because user_key is "" (not None). That results in query_param_key being set to an empty string, which will produce a malformed URL param name and the frontend hook will treat it as “not bound”. Consider rejecting empty-string keys here (e.g. treat user_key == "" as invalid) and raising a StreamlitAPIException with a clear message.
    # Validate that widget with bind="query-params" has a provided key
    if bind == "query-params":
        user_key = user_key_from_element_id(element_id)
        if user_key is None:
            raise StreamlitAPIException(
                "Widget must have a 'key' parameter when using bind='query-params'. "
                "The key will be used as the query parameter name."
            )

@mayagbarnes mayagbarnes changed the base branch from graphite-base/13683 to query-param-bind-2 January 23, 2026 23:26
@lukasmasuch
Copy link
Copy Markdown
Collaborator

@cusor review

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

Summary

This PR (Part 3 of a series) adds a bind parameter to st.checkbox, st.toggle, and st.color_picker widgets, enabling two-way synchronization between widget values and URL query parameters. The implementation includes:

  • URL seeding: Widgets initialize from URL query params on page load
  • URL sync: Widget value changes update the URL in real-time
  • Default cleanup: URL param is removed when widget returns to its default value
  • Hex color validation: Color picker validates and normalizes hex colors from URLs
  • Error handling: New StreamlitInvalidBindValueError for invalid bind values
  • Reusable hook: useQueryParamBinding hook extracted for frontend consistency

Code Quality

Python Backend (Good)

The implementation follows Streamlit patterns well:

  • bind is properly added as a keyword-only argument in all three widget APIs
  • The BindOption type annotation is correctly used
  • Documentation is comprehensive in docstrings with clear explanations
  • Module-level regex _HEX_COLOR_RE at line 55 of color_picker.py follows best practices for static data
  • The _normalize_hex_color helper function is appropriately scoped as a module-private function
# Compiled regex for validating hex colors (#RGB or #RRGGBB format)
_HEX_COLOR_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}){1,2}$")

Frontend TypeScript (Good)

  • The useQueryParamBinding hook is well-designed with proper cleanup in the useEffect
  • Uses the effect appropriately for external system synchronization (registering with WidgetStateManager)
  • Dependencies array is complete and correct
  • The hook follows the established pattern for similar bindings in the codebase
  useEffect(() => {
    // Treat null and undefined the same - no binding
    if (!queryParamKey) {
      return
    }

    widgetMgr.registerQueryParamBinding(
      widgetId,
      queryParamKey,
      valueType,
      defaultValue,
      options?.urlFormat,
      options?.optionStrings
    )

    return () => {
      widgetMgr.unregisterQueryParamBinding(widgetId)
    }
  }, [
    widgetMgr,
    widgetId,
    queryParamKey,
    valueType,
    defaultValue,
    options?.urlFormat,
    options?.optionStrings,
  ])

Protobuf Changes (Good)

  • Uses optional string query_param_key which is backwards compatible
  • Field numbers are sequential and don't conflict with existing fields
  • Appropriate comments added for documentation

Test Coverage

Python Unit Tests (Excellent)

Coverage is comprehensive with parameterized tests for checkbox/toggle:

  • test_bind_query_params_sets_query_param_key: Verifies proto field is set correctly
  • test_bind_query_params_without_key_raises_exception: Tests the required key validation
  • test_no_bind_does_not_set_query_param_key: Negative test for non-bound widgets
  • test_invalid_bind_value_raises_exception: Tests error handling for invalid bind values

The tests use @parameterized.expand to cover both checkbox and toggle efficiently.

TypeScript Unit Tests (Excellent)

The useQueryParamBinding.test.ts covers:

  • Registration with valid queryParamKey
  • No registration when queryParamKey is undefined
  • Proper unregistration on unmount
  • Options passing (urlFormat, optionStrings)
  • Re-registration when queryParamKey changes

Component tests (Checkbox.test.tsx, ColorPicker.test.tsx) verify:

  • Registration on mount with queryParamKey set
  • Unregistration on unmount
  • No registration when queryParamKey is not set
  • Binding with different default values

E2E Tests (Excellent)

Comprehensive coverage for all three widgets (checkbox, toggle, color_picker):

  • test_*_query_param_seeding: Tests URL param → widget initialization
  • test_*_query_param_updates_url: Tests widget → URL sync
  • test_*_query_param_default_*: Tests default value handling and param removal
  • test_*_query_param_invalid_value: Tests graceful handling of invalid URL values

E2E tests follow best practices:

  • Use expect() for auto-wait assertions
  • Use helper utilities from app_utils (e.g., click_checkbox, get_toggle)
  • Include negative assertions (param should NOT be in URL)
  • Use wait_for_app_loaded and wait_for_app_run appropriately

Backwards Compatibility

Fully Backwards Compatible

  • The bind parameter defaults to None - existing code works unchanged
  • Proto changes use optional fields with new field numbers
  • No changes to existing widget behavior when bind is not specified
  • The validation in register_widget only runs when bind is not None

Security & Risk

Low Risk

  • Input validation is proper: Invalid bind values raise clear errors
  • Color picker validates hex format from URL params, preventing injection
  • Invalid URL values are gracefully handled (widget uses default, param is cleared)
  • No new external dependencies introduced

Minor Consideration: The hex color normalization in ColorPickerSerde.deserialize raises a ValueError for invalid hex colors, which the widget manager handles appropriately.

Recommendations

No blocking issues found. The following are minor observations:

  1. Good practice already followed: The PR correctly adds "bind" to the excluded parameters list in widgets_test.py with an explanatory comment, ensuring it doesn't affect widget identity computation.

  2. Documentation consistency: The bind parameter documentation is consistent across all three widgets, which is good for user experience.

  3. Test organization: The E2E tests properly increment the element counts (CHECKBOX_ELEMENTS, TOGGLE_ELEMENTS, NUM_COLOR_PICKERS) to account for the new test widgets added to the app files.

Verdict

APPROVED: This PR is well-implemented with comprehensive test coverage across Python unit tests, TypeScript unit tests, and E2E tests. The code follows established patterns, is fully backwards compatible, and includes proper error handling. The reusable useQueryParamBinding hook provides a clean abstraction for future widget bindings.


This is an automated AI review using opus-4.5-thinking. Please verify the feedback and use your judgment.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Jan 26, 2026

📈 Significant bundle size change detected

Metric This Branch develop Change (%)
Total (gzip) 8.14 MiB 8.14 MiB +0.02%
Entry (gzip) 722.01 KiB 717.64 KiB +0.61%

Please verify that this change is expected.

📊 View detailed bundle comparison


it("re-registers when queryParamKey changes", () => {
const { rerender } = renderHook(
({ queryParamKey }) =>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The TypeScript Development Guide requires explicit return types for functions. The test function parameter queryParamKey in the renderHook callback is missing a type annotation. Add a type annotation like ({ queryParamKey }: { queryParamKey: string | undefined }) =>.

Suggested change
({ queryParamKey }) =>
({ queryParamKey }: { queryParamKey: string | undefined }) =>

Spotted by Graphite Agent (based on custom rule: TypeScript Guide)

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Comment on lines +254 to +260
queryParamBinding
? {
urlFormat: queryParamBinding.urlFormat,
optionStrings: queryParamBinding.optionStrings,
}
: undefined
)
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.

suggestion: This needs to be memoized by useMemo or else this is going to cause the downstream effect in useQueryParamBinding to run on every render due to this object being recreated each time this code is executed.

# Normalize first (add # prefix if missing), then validate
normalized = _normalize_hex_color(ui_value)
if not _HEX_COLOR_RE.match(normalized):
raise ValueError(f"Invalid hex color: {ui_value}")
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.

question: Are we sure that raising in deserialize is a pattern we want to introduce? I believe all other deserialize functions tend to provide a fallback value instead. I'm not sure what would happen if we were to raise here, so might be worth looking at aligning this with other patterns for deserialize.

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