Skip to content

[feature] st.expander & st.popover state persistence and CSS key class#14356

Merged
sfc-gh-lwilby merged 13 commits intodevelopfrom
feature/expander-popover-state-persistence
Mar 20, 2026
Merged

[feature] st.expander & st.popover state persistence and CSS key class#14356
sfc-gh-lwilby merged 13 commits intodevelopfrom
feature/expander-popover-state-persistence

Conversation

@sfc-gh-lwilby
Copy link
Copy Markdown
Collaborator

@sfc-gh-lwilby sfc-gh-lwilby commented Mar 12, 2026

Summary

Third PR in the layout container state persistence stack (after #14332 and #14354).

Adds frontend state persistence for keyed st.expander and st.popover elements via elementStates. When a key is provided in passive mode (on_change="ignore"), the expanded/open state survives component remounts caused by delta path shifts.

Changes

Frontend (Expander.tsx)

  • Reads stored expanded state from elementStates on mount when shouldPersist is true
  • Writes expanded state to elementStates on toggle via handlePersistToggle
  • Widget mode (on_change="rerun") is explicitly protected — elementStates is never read/written; server state always takes precedence

Frontend (Popover.tsx)

  • Same pattern: reads/writes open state to elementStates in passive mode
  • Widget mode protection identical to Expander

Frontend (Block.tsx)

  • ContainerContentsWrapper suppresses the st-key-* CSS class on the inner StyledFlexContainerBlock when it detects the block is inside an expander or popover, avoiding duplication with the container's own outermost element

CSS Key Class

  • st-key-* class applied to the outermost element of both st.expander and st.popover

Test Plan

  • Unit tests: passive state persistence (read on mount, write on toggle)
  • Unit tests: widget-mode guards (no elementStates read/write, server value wins over stale stored state)
  • Unit tests: CSS key class presence/absence
  • E2E tests: test_keyed_expander_css_key_class and test_keyed_expander_persist_expanded_across_remount
  • E2E tests: test_keyed_popover_css_key_class and test_keyed_popover_persist_closed_across_remount

Depends on: #14354

Copy link
Copy Markdown
Collaborator Author

sfc-gh-lwilby commented Mar 12, 2026

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 12, 2026

✅ PR preview is ready!

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

@snyk-io
Copy link
Copy Markdown
Contributor

snyk-io bot commented Mar 12, 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.

@sfc-gh-lwilby sfc-gh-lwilby changed the title feat: st.expander & st.popover state persistence and CSS key class [feature] st.expander & st.popover state persistence and CSS key class Mar 12, 2026
@sfc-gh-lwilby sfc-gh-lwilby added change:feature PR contains new feature or enhancement implementation impact:users PR changes affect end users labels Mar 12, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 12, 2026

📉 Python coverage change detected

The Python unit test coverage has decreased by 0.0000%

  • Current PR: 93.5891% (24271 statements, 1556 missed)
  • Latest develop: 93.5891% (24271 statements, 1556 missed)

✅ Coverage change is within normal range.

Coverage by files
Name Stmts Miss Cover
streamlit/__init__.py 140 0 100%
streamlit/__main__.py 3 3 0%
streamlit/auth_util.py 216 11 95%
streamlit/cli_util.py 33 0 100%
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 128 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 35 3 91%
streamlit/components/v1/components.py 4 4 0%
streamlit/components/v1/custom_component.py 82 5 94%
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 14 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 65 5 92%
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 420 11 97%
streamlit/config_option.py 77 3 96%
streamlit/config_util.py 308 7 98%
streamlit/connections/__init__.py 6 0 100%
streamlit/connections/base_connection.py 51 0 100%
streamlit/connections/snowflake_connection.py 98 16 84%
streamlit/connections/snowpark_connection.py 46 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 497 40 92%
streamlit/delta_generator.py 253 7 97%
streamlit/delta_generator_singletons.py 86 7 92%
streamlit/deprecation_util.py 66 4 94%
streamlit/development.py 1 0 100%
streamlit/elements/__init__.py 0 0 100%
streamlit/elements/alert.py 72 0 100%
streamlit/elements/arrow.py 222 23 90%
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 7 93%
streamlit/elements/dialog_decorator.py 38 0 100%
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/help.py 227 9 96%
streamlit/elements/html.py 49 0 100%
streamlit/elements/iframe.py 29 0 100%
streamlit/elements/image.py 36 0 100%
streamlit/elements/json.py 48 6 88%
streamlit/elements/layouts.py 250 5 98%
streamlit/elements/lib/__init__.py 0 0 100%
streamlit/elements/lib/built_in_chart_utils.py 401 29 93%
streamlit/elements/lib/color_util.py 103 4 96%
streamlit/elements/lib/column_config_utils.py 169 1 99%
streamlit/elements/lib/column_types.py 201 4 98%
streamlit/elements/lib/dialog.py 67 0 100%
streamlit/elements/lib/dicttools.py 39 2 95%
streamlit/elements/lib/file_uploader_utils.py 54 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_expander_container.py 19 0 100%
streamlit/elements/lib/mutable_popover_container.py 19 0 100%
streamlit/elements/lib/mutable_status_container.py 71 3 96%
streamlit/elements/lib/mutable_tab_container.py 19 0 100%
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 58 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 69 2 97%
streamlit/elements/media.py 182 8 96%
streamlit/elements/metric.py 102 0 100%
streamlit/elements/pdf.py 47 0 100%
streamlit/elements/plotly_chart.py 129 6 95%
streamlit/elements/progress.py 36 0 100%
streamlit/elements/pyplot.py 37 0 100%
streamlit/elements/snow.py 10 0 100%
streamlit/elements/space.py 12 0 100%
streamlit/elements/spinner.py 44 3 93%
streamlit/elements/table.py 56 3 95%
streamlit/elements/text.py 16 0 100%
streamlit/elements/toast.py 26 0 100%
streamlit/elements/vega_charts.py 244 5 98%
streamlit/elements/widgets/__init__.py 0 0 100%
streamlit/elements/widgets/audio_input.py 68 1 99%
streamlit/elements/widgets/button.py 265 6 98%
streamlit/elements/widgets/button_group.py 202 15 93%
streamlit/elements/widgets/camera_input.py 62 1 98%
streamlit/elements/widgets/chat.py 230 36 84%
streamlit/elements/widgets/checkbox.py 54 0 100%
streamlit/elements/widgets/color_picker.py 70 2 97%
streamlit/elements/widgets/data_editor.py 254 14 94%
streamlit/elements/widgets/feedback.py 69 0 100%
streamlit/elements/widgets/file_uploader.py 108 1 99%
streamlit/elements/widgets/menu_button.py 81 0 100%
streamlit/elements/widgets/multiselect.py 118 5 96%
streamlit/elements/widgets/number_input.py 153 4 97%
streamlit/elements/widgets/radio.py 105 5 95%
streamlit/elements/widgets/select_slider.py 127 4 97%
streamlit/elements/widgets/selectbox.py 99 3 97%
streamlit/elements/widgets/slider.py 256 12 95%
streamlit/elements/widgets/text_widgets.py 144 6 96%
streamlit/elements/widgets/time_widgets.py 465 25 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 193 25 87%
streamlit/external/__init__.py 0 0 100%
streamlit/external/langchain/__init__.py 2 0 100%
streamlit/external/langchain/streamlit_callback_handler.py 137 78 43%
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 125 2 98%
streamlit/net_util.py 55 3 95%
streamlit/path_security.py 17 1 94%
streamlit/platform.py 10 1 90%
streamlit/runtime/__init__.py 8 0 100%
streamlit/runtime/app_session.py 466 89 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 173 4 98%
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 35 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 110 1 99%
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 201 13 94%
streamlit/runtime/pages_manager.py 59 2 97%
streamlit/runtime/runtime.py 250 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 231 25 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 104 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 60 1 98%
streamlit/runtime/state/presentation.py 19 4 79%
streamlit/runtime/state/query_params.py 343 7 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 543 36 93%
streamlit/runtime/state/session_state_proxy.py 62 8 87%
streamlit/runtime/state/widgets.py 23 0 100%
streamlit/runtime/stats.py 132 4 97%
streamlit/runtime/theme_util.py 46 1 98%
streamlit/runtime/uploaded_file_manager.py 39 2 95%
streamlit/runtime/websocket_session_manager.py 112 0 100%
streamlit/source_util.py 34 1 97%
streamlit/starlette.py 2 0 100%
streamlit/string_util.py 106 9 92%
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 261 4 98%
streamlit/testing/v1/element_tree.py 1569 63 96%
streamlit/testing/v1/local_script_runner.py 75 2 97%
streamlit/testing/v1/util.py 17 0 100%
streamlit/time_util.py 27 0 100%
streamlit/type_util.py 152 13 91%
streamlit/url_util.py 42 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 40 1 98%
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 176 21 88%
streamlit/web/cache_storage_manager_config.py 5 0 100%
streamlit/web/cli.py 184 12 93%
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 34 3 91%
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 20 86%
streamlit/web/server/component_file_utils.py 27 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 35 78%
streamlit/web/server/oidc_mixin.py 46 0 100%
streamlit/web/server/routes.py 94 10 89%
streamlit/web/server/server.py 195 13 93%
streamlit/web/server/server_util.py 87 5 94%
streamlit/web/server/starlette/__init__.py 3 0 100%
streamlit/web/server/starlette/starlette_app.py 148 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_path_security_middleware.py 22 0 100%
streamlit/web/server/starlette/starlette_routes.py 341 82 76%
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 70 6 91%
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 24271 1556 94%

📊 View detailed coverage comparison

@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from feature/tabs-state-persistence to graphite-base/14356 March 12, 2026 17:44
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the feature/expander-popover-state-persistence branch from 03b6f5c to 4bdb35b Compare March 12, 2026 17:47
@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from graphite-base/14356 to feature/tabs-state-persistence March 12, 2026 17:47
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 13, 2026

📈 Frontend coverage change detected

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

  • Current PR: 87.7800% (14706 lines, 1797 missed)
  • Latest develop: 87.7600% (14690 lines, 1797 missed)

✅ Coverage change is within normal range.

📊 View detailed coverage comparison

@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from feature/tabs-state-persistence to graphite-base/14356 March 13, 2026 19:05
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the feature/expander-popover-state-persistence branch from 45b6da3 to f5b99b4 Compare March 13, 2026 19:47
@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from graphite-base/14356 to feature/tabs-state-persistence March 13, 2026 19:47
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the feature/tabs-state-persistence branch from 61d3607 to b3a3afe Compare March 17, 2026 15:16
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the feature/expander-popover-state-persistence branch from 915c63c to fed2534 Compare March 17, 2026 15:16
@sfc-gh-lwilby sfc-gh-lwilby added the ai-review If applied to PR or issue will run AI review workflow label Mar 17, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 17, 2026

✅ Bundle size change is within normal range

Metric This Branch develop Change (%)
Total (gzip) 7.85 MiB 7.85 MiB +0.00%
Entry (gzip) 97.3 KiB 97.29 KiB +0.01%

📊 View detailed bundle comparison

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 17, 2026

📈 Significant wheel size change detected

The wheel file size has increased by 1.46% (threshold: 0.25%)

  • Current PR: 8886.03 KB
  • Latest develop: 8758.59 KB

Please verify that this change is expected.

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

Summary

This PR adds passive frontend state persistence for keyed st.expander and st.popover elements via elementStates. When a key is provided in passive mode (on_change="ignore"), the expanded/open state survives component remounts caused by delta path shifts. Widget mode (on_change="rerun" / callback) remains server-authoritative. The CSS st-key-* class is also moved to the outermost element (StyledLayoutWrapper) for expander and popover container types, and frontend + E2E test coverage is expanded.

Changes span 10 files across frontend components (Expander.tsx, Popover.tsx, Block.tsx), their unit tests, and E2E tests for both elements.

Code Quality

The implementation is well-structured and follows existing codebase patterns. Both reviewers agreed on the following:

  1. Clean separation of widget/passive modes: Both Expander.tsx and Popover.tsx use an explicit isWidget / isPassivelyKeyed distinction, with widget mode correctly taking precedence over passive persistence.

  2. Rules of Hooks compliance: The useWidgetManagerElementState hook is always called regardless of mode, with an empty-string id as a fallback when not in passive mode, correctly satisfying the Rules of Hooks.

  3. Consistent patterns: Both components follow the same persistence strategy, making the code predictable and maintainable.

  4. Minor code smell (both reviewers flagged): In Expander.tsx and Popover.tsx, comments describe the empty-id path as a "no-op entry," but useWidgetManagerElementState actually writes to elementStates under the empty-string key via its internal useEffect. This is functionally harmless but the comment is misleading. Both reviewers recommend clarifying.

  5. Incremental keyClassOnWrapper approach: The keyClassOnWrapper flag in Block.tsx is intentionally gated per container type with a clear explanatory comment.

Test Coverage

Both reviewers rated unit test coverage as excellent:

  • Widget mode guards (no elementStates read/write, server value wins over stale stored state)
  • Passive persistence (read on mount, write on toggle, proto default when no stored state)
  • Boundary cases (no blockId, widget mode with blockId)
  • CSS key class placement (on StyledLayoutWrapper for expander/popover, on stVerticalBlock for plain containers)
  • Element count assertions correctly updated (21 -> 22) in both E2E test files

Both reviewers identified the same E2E gap:

  • There is no E2E test for keyed popover open/closed persistence across a remount-inducing delta-path shift (analogous to the new expander scenario test_keyed_expander_persist_expanded_across_remount).
  • The opus-4.6-thinking reviewer additionally noted that the PR description claims test_keyed_popover_persist_closed_across_remount is completed, but this test does not exist in the code. The popover E2E app (st_popover.py) also lacks the toggle mechanism needed to test remount persistence.

Backwards Compatibility

Low risk overall, both reviewers agreed. No backend/protobuf changes; persistence is purely frontend-side via in-memory elementStates.

Two related areas to monitor:

  1. CSS st-key-* class moved to outer wrapper (both reviewers noted): For keyed expander/popover, st-key-* is now on StyledLayoutWrapper rather than the previous inner placement. Custom CSS selectors depending on the old DOM/class combination may need adjustment.

  2. CSS key class gap for other container types (opus-4.6-thinking uniquely identified): The removal of convertKeyToClassName from ContainerContentsWrapper silently drops the st-key-* class for any container type where keyClassOnWrapper is false. For form and chatMessage container types, the key class could be lost. In practice, st.form() uses key as formId and st.chat_message() doesn't expose a key parameter, so impact is negligible — but this should be tracked for future container types.

Security & Risk

Both reviewers agreed: No security concerns identified. No changes to WebSocket handling, authentication, session management, server endpoints, external dependencies, browser storage, or HTML rendering. elementStates is purely in-memory. Risk profile is low and limited to UI-state behavior.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence: All changes are frontend-only React component logic and in-memory state management. No routing, auth, WebSocket, embedding, asset serving, CORS, storage, or security header changes.
  • Confidence: High (both reviewers agreed)

Accessibility

Both reviewers agreed: No accessibility regressions. No new interactive elements or ARIA attributes are introduced. Existing inert (expander) and aria-expanded (popover) behavior is unchanged. Focus management and keyboard navigation are not affected.

Reviewer Agreement Matrix

Topic gpt-5.3-codex-high opus-4.6-thinking Consensus
Overall verdict APPROVED APPROVED APPROVED
Widget/passive mode separation Clean Clean Agreed
"No-op entry" comment misleading Yes Yes Agreed
Missing popover E2E persistence test Flagged Flagged (+ noted PR description mismatch) Agreed
CSS key class backward compat Noted Noted (+ deeper container analysis) Agreed
Security risk None None Agreed
External test needed No No Agreed
Accessibility concerns None None Agreed

No disagreements between the two available reviewers.

Missing Reviews

  • gemini-3.1-pro: Failed to complete review.

Recommendations

  1. Add the missing popover E2E persistence test (both reviewers): The PR description claims test_keyed_popover_persist_closed_across_remount is completed, but it doesn't exist. Add a toggle mechanism in st_popover.py (similar to the expander's persist_expander_toggle) and implement the E2E test to validate popover state persistence across delta path shifts.

  2. Track the CSS key class gap for other container types (opus-4.6-thinking): Consider adding keyClassOnWrapper = true for other container types, or applying the key class on StyledLayoutWrapper unconditionally. The current gated approach is fine for this PR but should be tracked.

  3. Clarify the "no-op entry" comment (both reviewers): In both Expander.tsx and Popover.tsx, update the comment to something like "the empty id produces a harmless shared entry that is never read back" to accurately describe behavior.

Verdict

APPROVED: Both available reviewers approved. The implementation is well-structured with thorough unit test coverage, clean separation of concerns, and low risk. The missing popover E2E persistence test (recommendation 1) and the CSS key class gap tracking (recommendation 2) are noted for follow-up but are non-blocking given the incremental approach and low practical risk.


This is a consolidated AI review by opus-4.6-thinking, synthesizing reviews from gpt-5.3-codex-high and opus-4.6-thinking. gemini-3.1-pro failed to complete review.


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

Summary

This PR adds passive frontend state persistence for keyed st.expander and st.popover when on_change="ignore" by using elementStates, while keeping widget mode (on_change="rerun" / callback) server-authoritative. It also moves st-key-* placement for expander/popover to the outer layout wrapper and expands frontend + e2e coverage for these behaviors.

Code Quality

Overall implementation quality is good: the widget-mode vs passive-mode split is explicit, and behavior is covered with targeted tests in Expander, Popover, and Block components.

No blocking code-quality defects found.

Minor maintainability note:

  • In frontend/lib/src/components/elements/Expander/Expander.tsx and frontend/lib/src/components/elements/Popover/Popover.tsx, comments describe the empty-id path as a "no-op entry", but useWidgetManagerElementState still writes to elementStates[""] internally. This is not currently user-visible in this PR's flows, but the comment is slightly misleading versus actual behavior.

Test Coverage

Coverage is strong for the main logic:

  • Frontend unit tests added for passive restore/persist behavior, widget-mode guardrails, and stale-state precedence in frontend/lib/src/components/elements/Expander/Expander.test.tsx and frontend/lib/src/components/elements/Popover/Popover.test.tsx.
  • Wrapper key-class placement is validated in frontend/lib/src/components/core/Block/Block.test.tsx.
  • E2E coverage includes keyed expander class + remount persistence in e2e_playwright/st_expander_test.py, and keyed popover class in e2e_playwright/st_popover_test.py.

One non-blocking gap remains:

  • There is no new e2e scenario asserting keyed popover open/closed persistence across a remount-inducing delta-path shift (analogous to the new expander scenario).

Backwards Compatibility

No API-level breaking changes detected.

Behavioral compatibility note:

  • For keyed expander/popover, st-key-* is now attached on the outer layout wrapper rather than the previous inner placement. This appears intentional and improves "outermost element" targeting, but custom CSS selectors that depended on the old DOM/class combination may need adjustment.

Security & Risk

No security-sensitive surfaces were changed (no auth/session/websocket/headers/upload/network/external dependency changes). Risk profile is low and primarily UI-state behavior.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • frontend/lib/src/components/elements/Expander/Expander.tsx: frontend-only local UI persistence and callback routing between widget/passive modes.
    • frontend/lib/src/components/elements/Popover/Popover.tsx: frontend-only open-state persistence and existing widget sync behavior.
    • frontend/lib/src/components/core/Block/Block.tsx: CSS class placement change on wrappers; no routing/auth/network/session transport changes.
    • e2e_playwright/st_expander_test.py and e2e_playwright/st_popover_test.py: test-only updates.
  • Suggested external_test focus areas:
    • None required for this PR.
  • Confidence: High
  • Assumptions and gaps: Assessment assumes no hidden server/runtime changes outside the listed diff.

Accessibility

No accessibility regressions identified in the changed code. Interaction semantics for expander/popover triggers remain unchanged, and this PR mainly adjusts state persistence and class placement.

Recommendations

  1. Add an e2e test for keyed popover persistence across remount (delta-path shift), matching the expander persistence scenario to improve end-to-end confidence.
  2. Align comments with behavior (or guard the hook path) regarding empty-id handling in passive-state hooks to avoid future confusion.

Verdict

APPROVED: The implementation is sound, low risk, and merge-ready, with one worthwhile non-blocking e2e enhancement for popover remount persistence.


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 adds frontend state persistence for keyed st.expander and st.popover elements via elementStates. When a key is provided in passive mode (on_change="ignore"), the expanded/open state survives component remounts caused by delta path shifts. The CSS st-key-* class is also moved to the outermost element (StyledLayoutWrapper) for these two container types to avoid duplication with the inner vertical block.

Changes span 10 files across frontend components (Expander.tsx, Popover.tsx, Block.tsx), their unit tests, and E2E tests for both elements.

Code Quality

The code is well-structured and follows existing patterns in the codebase. Key observations:

  1. Clean separation of widget/passive modes: Both Expander.tsx and Popover.tsx use a clear isWidget / isPassivelyKeyed distinction. Widget mode always takes precedence over passive persistence, which is the correct priority order.

  2. Rules of Hooks compliance: The useWidgetManagerElementState hook is always called regardless of mode, with an empty-string id as a no-op path when not in passive mode. This correctly satisfies the Rules of Hooks while being functionally inert for non-passive cases.

  3. Incremental keyClassOnWrapper approach: The keyClassOnWrapper flag in Block.tsx (lines 293-297) is intentionally gated per container type. The comment explains the rationale well. However, this has a side effect discussed below under Recommendations.

  4. Minor code smell — empty-string id in useWidgetManagerElementState: When isPassivelyKeyed is false, the hook is called with id="". The comment at Expander.tsx:98-99 / Popover.tsx:79-81 describes this as a "no-op entry," but the hook actually writes to elementStates under key ("", "expanded"/"open") via its internal useEffect. This is functionally harmless (the stored value is never meaningfully read back), but "no-op" is slightly misleading.

  5. Consistent patterns between Expander and Popover: Both components follow the same persistence strategy, making the code predictable and maintainable.

Test Coverage

Frontend unit tests — Excellent coverage with both positive and negative assertions:

  • Widget mode guards (no elementStates read/write, server value wins over stale stored state)
  • Passive persistence (read on mount, write on toggle, proto default when no stored state)
  • Boundary cases (no blockId, widget mode with blockId)
  • CSS key class placement (on StyledLayoutWrapper for expander, on stVerticalBlock for plain containers)

E2E tests — Good but with a gap:

  • test_keyed_expander_css_key_class and test_keyed_expander_persist_expanded_across_remount are well-written. The persistence test uses a toggle to shift delta paths and verifies state survival across two remounts.
  • test_keyed_popover_css_key_class is present.
  • Missing: The PR description's test plan lists test_keyed_popover_persist_closed_across_remount as a completed item, but this test does not exist in the code. The popover E2E app (st_popover.py) also lacks the toggle mechanism (like the expander has) that would be needed to test remount persistence. This is a gap — while popover persistence is covered by unit tests, there's no E2E validation of it surviving a delta path shift.

Element count assertions are correctly updated in both test files (21 → 22).

Backwards Compatibility

Low risk overall, with one area to monitor:

  1. CSS key class removed from ContainerContentsWrapper (Block.tsx lines 142-144): The convertKeyToClassName(userKey) was removed from StyledFlexContainerBlock inside ContainerContentsWrapper. This inner component is used as the child for all container types (expander, popover, form, chatMessage, etc.).

    • For expander and popover: The key class is correctly moved to StyledLayoutWrapper via keyClassOnWrapper = true.
    • For FlexBoxContainer (regular st.container): Unaffected — FlexBoxContainer applies its own key class at Block.tsx:214.
    • For form and chatMessage container types: keyClassOnWrapper remains false, so StyledLayoutWrapper gets convertKeyToClassName(undefined) which returns "". The inner StyledFlexContainerBlock also no longer has the key class. If these container types have block-level IDs with user keys, the st-key-* CSS class would be silently dropped.

    In practice, st.form() uses key as formId and st.chat_message() doesn't expose a key parameter, so this is unlikely to affect real users. The incremental gating approach is reasonable, but this behavior change should be documented or tracked for future container types.

  2. No backend/protobuf changes: The persistence is purely frontend-side via elementStates, which is an in-memory store in the WidgetStateManager. No wire protocol or server changes are needed.

Security & Risk

No security concerns identified:

  • No changes to WebSocket handling, authentication, session management, or server endpoints
  • No new external dependencies or asset fetching
  • elementStates is an in-memory frontend store — no browser storage (localStorage/sessionStorage/cookies) is used
  • No HTML/Markdown rendering changes or XSS vectors
  • No eval/exec patterns introduced

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • All changes are frontend-only React component logic and in-memory state management
    • No routing, auth, WebSocket, embedding, asset serving, CORS, storage, or security header changes
    • elementStates uses WidgetStateManager (in-memory), not browser storage APIs
  • Suggested external_test focus areas: N/A
  • Confidence: High
  • Assumptions and gaps: None — changes are clearly scoped to frontend component rendering and state

Accessibility

No accessibility concerns. The changes are purely about state persistence logic and CSS class placement:

  • No new interactive elements or ARIA attributes are introduced
  • The expander's existing inert attribute handling is unchanged
  • The popover's existing aria-expanded behavior is unchanged
  • Focus management and keyboard navigation are not affected

Recommendations

  1. Add the missing popover E2E persistence test: The PR description claims test_keyed_popover_persist_closed_across_remount is completed, but it doesn't exist. Add a toggle mechanism in st_popover.py (similar to the expander's persist_expander_toggle) and implement the E2E test to validate popover state persistence across delta path shifts.

  2. Track the CSS key class gap for other container types: The removal of convertKeyToClassName from ContainerContentsWrapper silently drops the st-key-* class for any container type where keyClassOnWrapper is false and the block goes through the containerElementStyledLayoutWrapper path. Consider adding keyClassOnWrapper = true for form and chatMessage blocks, or re-applying the key class on StyledLayoutWrapper unconditionally (i.e., always pass userKey instead of gating on keyClassOnWrapper). The current gated approach is fine for this PR, but should be tracked.

  3. Clarify the "no-op entry" comment: In both Expander.tsx:98-99 and Popover.tsx:79-81, the comment says "the empty id produces a no-op entry." Since the hook does write to elementStates (under the empty-string key), consider clarifying to something like "the empty id produces a harmless shared entry that is never read back."

Verdict

APPROVED: Well-structured implementation of passive state persistence for expander and popover with thorough unit test coverage. The missing popover E2E persistence test (item 1) and the CSS key class gap for other container types (item 2) are noted for follow-up but are non-blocking given the incremental approach and low practical risk.


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

📋 Review by `gemini-3.1-pro`

⚠️ This model failed to complete its review.

@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from feature/tabs-state-persistence to graphite-base/14356 March 19, 2026 07:03
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the feature/expander-popover-state-persistence branch from a5f9358 to 5a46145 Compare March 19, 2026 07:04
@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from graphite-base/14356 to feature/tabs-state-persistence March 19, 2026 07:04
Base automatically changed from feature/tabs-state-persistence to develop March 19, 2026 07:39
sfc-gh-lwilby and others added 2 commits March 19, 2026 08:53
Add frontend state persistence for keyed expanders and popovers via
elementStates. When a key is provided (passive mode), the open/expanded
state survives component remounts caused by delta path shifts.

- Expander reads/writes "expanded" state to elementStates on toggle
- Popover reads/writes "open" state to elementStates on toggle
- CSS key class (st-key-*) applied to outermost element only
- Block.tsx suppresses duplicate CSS key class on inner vertical blocks
  for expander/popover containers
- Widget mode (on_change="rerun") is explicitly protected: elementStates
  is never read/written, server state always takes precedence
- Unit tests cover persistence, CSS class, and widget-mode guards
- E2E tests for CSS key class and persistence for both elements

Co-authored-by: lawilby <[email protected]>
- Remove non-null assertions on blockId (already guarded by shouldPersist)
- Remove unnecessary async and unused variable in Popover test

Co-authored-by: lawilby <[email protected]>
sfc-gh-lwilby and others added 9 commits March 19, 2026 08:53
blockId is typed as string | undefined. The shouldPersist guard ensures
it's truthy at runtime, but TypeScript can't narrow through the ternary.
Use blockId ?? "" to satisfy the string parameter type.

Co-authored-by: lawilby <[email protected]>
The test only verified the default (closed) state after remount, which
would pass regardless of persistence. The underlying elementStates
mechanism is already covered by expander and tabs E2E tests. The CSS
key class test is kept.

Co-authored-by: lawilby <[email protected]>
- Expander/Popover: replace manual getElementState/setElementState with
  useWidgetManagerElementState hook for cleaner state persistence
- CSS key class: apply st-key-* on StyledLayoutWrapper (per spec) for
  expander/popover only; suppress duplicate on ContainerContentsWrapper
- Fix Popover bug: only persist state when isPassivelyKeyed (not on
  every non-widget toggle)
- Unit tests: rewrite persistence tests to verify behavior through
  public WidgetStateManager API rather than spying on internal calls
- Remove CSS class tests from Expander/Popover (now tested via Block)
- Add BlockNodeRenderer unit test verifying CSS class placement

Co-authored-by: lawilby <[email protected]>
…pressKeyClass

Pass the computed CSS key from outside rather than having the component
derive and conditionally suppress it. Expander/popover children simply
omit userKey; all other callers pass it.

Co-authored-by: lawilby <[email protected]>
Remove unused userKey prop from ContainerContentsWrapper (no container
that passes through it sets Block.id). Remove leftover persistence test
fixtures from st_popover.py after test removal. Update keyClassOnWrapper
comment to reflect per-container gating rationale.

Co-authored-by: lawilby <[email protected]>
- Reuse {child} in expander/popover instead of duplicating
  ContainerContentsWrapper (now identical after userKey removal).
- Remove dead userKey prop from ContainerContentsWrapper.
- Fix TS2322: add ?? false for element.expanded defaultValue.
- Update keyClassOnWrapper comment.

Co-authored-by: lawilby <[email protected]>
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the feature/expander-popover-state-persistence branch 3 times, most recently from 46b260c to b556bf1 Compare March 19, 2026 09:53
Make it explicit that element.id being set is what triggers widget mode
(on_change="rerun"), so the server value takes precedence.

Co-authored-by: lawilby <[email protected]>
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the feature/expander-popover-state-persistence branch from b556bf1 to 066863b Compare March 19, 2026 10:08
@sfc-gh-lwilby sfc-gh-lwilby marked this pull request as ready for review March 19, 2026 10:11
Copilot AI review requested due to automatic review settings March 19, 2026 10:11
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 passive (non-widget) state persistence for keyed layout containers (st.expander, st.popover) by storing UI state in WidgetStateManager.elementStates, and relocates st-key-* to the outermost wrapper element for these containers to avoid duplicate key classes.

Changes:

  • Persist expander expanded and popover open state across remounts in passive keyed mode via elementStates, while keeping widget-mode controlled by server state.
  • Move st-key-* CSS key class placement to StyledLayoutWrapper for expander/popover blocks and remove it from inner vertical block wrappers.
  • Add/adjust frontend unit tests and Playwright E2E tests for persistence + CSS key class behavior.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
frontend/lib/src/components/elements/Popover/Popover.tsx Adds elementStates-backed persistence for passive keyed popovers and widget-mode guarding.
frontend/lib/src/components/elements/Popover/Popover.test.tsx Adds unit tests for popover persistence + widget-mode behavior.
frontend/lib/src/components/elements/Expander/Expander.tsx Adds elementStates-backed persistence for passive keyed expanders and widget-mode guarding.
frontend/lib/src/components/elements/Expander/Expander.test.tsx Adds unit tests for expander persistence + widget-mode behavior.
frontend/lib/src/components/core/Block/Block.tsx Moves st-key-* to StyledLayoutWrapper for expander/popover and removes it from inner container wrapper.
frontend/lib/src/components/core/Block/Block.test.tsx Tests key class placement on wrapper vs inner block for expander/popover.
e2e_playwright/st_popover.py Adds a keyed “Persist popover” example to the E2E app.
e2e_playwright/st_popover_test.py Adds E2E checks for popover CSS key class + persistence behavior.
e2e_playwright/st_expander.py Adds a keyed “Persist expander” plus a toggle to force delta-path shifts.
e2e_playwright/st_expander_test.py Adds E2E checks for expander CSS key class + persistence across remounts.

Comment on lines +80 to +84
// The hook is always called (Rules of Hooks) but only effective when
// isPassivelyKeyed — otherwise the empty id produces a no-op entry.
const [storedOpen, setStoredOpen] = useWidgetManagerElementState<boolean>({
widgetMgr,
id: isPassivelyKeyed ? (blockId ?? "") : "",
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.

No longer applicable — useWidgetManagerElementState has been removed from Popover in subsequent refactoring. The component now uses plain useState + useExecuteWhenChanged for state management, with no elementStates read/write.

Comment on lines +99 to +105
// isPassivelyKeyed — otherwise the empty id produces a no-op entry.
const [storedExpanded, setStoredExpanded] =
useWidgetManagerElementState<boolean>({
widgetMgr,
id: isPassivelyKeyed ? (blockId ?? "") : "",
key: "expanded",
defaultValue: element.expanded ?? false,
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.

Same as above — useWidgetManagerElementState has been removed from Expander. The component now uses useDetailsAnimation which manages open/close state internally.

Comment on lines +337 to +338
// No blockId → toggled state (true) should NOT have been stored
expect(widgetMgr.getElementState("", "open")).not.toBe(true)
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.

No longer applicable — the Popover component no longer uses useWidgetManagerElementState, so there are no elementStates writes to test for.

await user.click(screen.getByText("hi"))

// No blockId → toggled state (true) should NOT have been stored
expect(widgetMgr.getElementState("", "expanded")).not.toBe(true)
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.

No longer applicable — same as the Popover test comment. The Expander component no longer uses useWidgetManagerElementState.

Comment on lines +360 to +368
def test_keyed_popover_persists_open_state_across_rerun(app: Page):
"""Test that a keyed popover stays open after a rerun triggered by keyboard shortcut."""
# Open the keyed popover
open_popover(app, "Persist popover")
expect(app.get_by_text("Persist popover content")).to_be_visible()

# Trigger a rerun via "r" keyboard shortcut — avoids clicking outside
# the popover which would close it
app.keyboard.press("r")
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.

Addressed in d2fb45d — the test now uses a checkbox inside the popover that conditionally inserts an element above it, shifting the delta path and forcing a true remount. Verifies persistence in both directions (check/uncheck).

Comment on lines +224 to +225
with st.popover("Persist popover", key="persist_popover"):
st.write("Persist popover content")
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.

Maybe a better e2e test case here might be a toggle in the popover that adds an element on top of the parent container of the popover?

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.

Good idea — addressed in d2fb45d. Added a checkbox inside the persist popover that sets persist_popover_shift in session state, which conditionally renders st.write("Extra text above popover") above the popover. The E2E test clicks the checkbox (delta-path shift → remount) and verifies the popover stays open.

Comment on lines -145 to +144
className={classNames(
getClassnamePrefix(Direction.VERTICAL),
convertKeyToClassName(userKey)
)}
className={getClassnamePrefix(Direction.VERTICAL)}
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.

Does this change the position of where the key is applied, e.g., for st.container?

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.

No, st.container has a different path that is unaffected and there is a test for that. The other container elements would be effected except they are not utilizing the class yet. This is why I decided to narrow it just to the ones we are explicitly adding even though that logic isn't strictly necessary, to make sure when it is added on other container elements we make a decision what div to add the class on.

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.

Ok 👍

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 👍

The previous test only triggered a rerun via the "r" shortcut, which
doesn't cause a remount. The new test places a checkbox inside the
keyed popover that conditionally inserts an element above it, shifting
the popover's delta path and forcing a true remount — validating that
elementStates persistence works across identity changes.

Made-with: Cursor

Co-authored-by: lawilby <[email protected]>
@sfc-gh-lwilby sfc-gh-lwilby merged commit 5feecf4 into develop Mar 20, 2026
45 of 46 checks passed
@sfc-gh-lwilby sfc-gh-lwilby deleted the feature/expander-popover-state-persistence branch March 20, 2026 19:21
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