Skip to content

[feature] st.tabs state persistence and CSS key class#14332

Merged
sfc-gh-lwilby merged 7 commits intodevelopfrom
feature/tabs-state-persistence
Mar 19, 2026
Merged

[feature] st.tabs state persistence and CSS key class#14332
sfc-gh-lwilby merged 7 commits intodevelopfrom
feature/tabs-state-persistence

Conversation

@sfc-gh-lwilby
Copy link
Copy Markdown
Collaborator

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

Summary

Implements state persistence and CSS key class application for st.tabs containers, building on the block identity infrastructure from the parent PR.

Changes

  • Tabs.tsx: When a st.tabs has a key and is passive (non-dynamic), persists the active tab label to elementStates via widgetMgr.setElementState/getElementState. On mount, restores the previously active tab if it's still valid. Applies the st-key-* CSS class to the outermost StyledTabContainer.
  • RenderNodeVisitor.tsx: Uses node.deltaBlock.id as the React key for block nodes when available, so keyed containers maintain component identity across positional shifts (e.g. when a conditional element above them causes a delta path change). Falls back to positional index for unkeyed blocks.
  • Frontend unit tests: Added tests for CSS key class application, state persistence on mount, storage on tab click, fallback behavior, and non-persistence for unkeyed/dynamic tabs. Added test for RenderNodeVisitor block keying.
  • E2E tests: Added st_tabs_state_persistence.py app and test suite verifying:
    • Keyed tabs persist the active tab across remounts caused by layout shifts
    • Unkeyed tabs reset to the default tab on remount
    • The st-key-* CSS class is correctly applied
    • Persistence works across multiple sequential tab switches

Test plan

  • Frontend unit tests pass (Tabs.test.tsx, RenderNodeVisitor.test.tsx)
  • E2E tests pass (st_tabs_state_persistence_test.py)
  • make check passes

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 11, 2026

✅ PR preview is ready!

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

Copy link
Copy Markdown
Collaborator Author

sfc-gh-lwilby commented Mar 11, 2026

@snyk-io
Copy link
Copy Markdown
Contributor

snyk-io bot commented Mar 11, 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 added impact:users PR changes affect end users change:feature PR contains new feature or enhancement implementation labels Mar 11, 2026
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the feature/tabs-state-persistence branch 2 times, most recently from fffd0a3 to e1546ea Compare March 12, 2026 09:57
@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.5922% (24236 statements, 1553 missed)
  • Latest develop: 93.5922% (24236 statements, 1553 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 332 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 519 34 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 24236 1553 94%

📊 View detailed coverage comparison


const [activeTabKey, setActiveTabKey] = useState<React.Key>(defaultTabIndex)
const [activeTabKey, setActiveTabKey] = useState<React.Key>(() => {
if (shouldPersist) {
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.

Let's name this something more specific.

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.

Renamed stored to storedActiveTabLabel, and then went further: extracted a getPersistedTabIndex() helper that deduplicates all 4 read-and-validate sites. The local variable inside the helper is now the only place that reads from elementStates.

const widgetId = node.deltaBlock?.tabContainer?.id
const blockId = node.deltaBlock?.id ?? ""
const isDynamic = Boolean(widgetId)
const shouldPersist = Boolean(blockId) && !isDynamic
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.

Let's add a comment to explain why these conditions.

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.

Added an explanatory comment and also renamed shouldPersist to isPassivelyKeyed — describes the condition more precisely: tabs that have a key (stable blockId) but are not dynamic widgets, so they persist locally rather than through the backend.

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

Summary

This PR builds on the parent branch's block identity infrastructure to add two capabilities for st.tabs:

  1. CSS key class: Keyed tabs now get an st-key-* CSS class on the outermost StyledTabContainer, matching the convention used by other keyed elements.
  2. Passive state persistence: When a st.tabs has a key (but no on_change, i.e. non-dynamic/passive), the active tab label is persisted to widgetMgr.elementStates. On mount or reconciliation, the stored label is restored if it still matches a valid tab.
  3. React key stability: RenderNodeVisitor now uses node.deltaBlock.id as the React key for block nodes (when available), so keyed containers maintain component identity across positional shifts caused by conditional elements.

Changes span 6 files: Tabs.tsx, Tabs.test.tsx, RenderNodeVisitor.tsx, RenderNodeVisitor.test.tsx, st_tabs.py, and st_tabs_test.py.

Code Quality

Overall: The code is well-structured and follows existing patterns. The changes integrate cleanly with the existing Tabs component architecture and the RenderNodeVisitor pattern.

Observations:

  1. Duplicated reconciliation logic (Tabs.tsx lines 92-113, 172-197, 219-252): The stored-state reading pattern (widgetMgr.getElementState(blockId, "activeTabLabel")) is repeated across three locations: state initializers, the [allTabLabels] reconciliation effect, and the [node.children.length] effect. While this is a pragmatic approach given the existing effect architecture (pre-existing eslint-disable-next-line react-hooks/exhaustive-deps comments), it increases maintenance burden. A future refactor could consolidate this into a single reconciliation function. This is not a blocking issue since the duplication is a consequence of the pre-existing effect structure.

  2. || undefined idiom (RenderNodeVisitor.tsx line 89): node.deltaBlock?.id || undefined correctly treats empty strings as "no id" (since empty string is falsy with ||). This is intentional and correct.

  3. Type assertions (Tabs.tsx): The as string | undefined casts on widgetMgr.getElementState() results are necessary given the any return type of the API. This is consistent with existing usage patterns.

  4. Variable extraction (Tabs.tsx line 269): The newLabel variable extraction in the onChange handler is a clean improvement — it avoids repeating allTabLabels[activeKey as number] and improves readability.

  5. Import addition: classNames is already a project dependency and is used by other components. The imports of convertKeyToClassName and getKeyFromId from Block/utils follow established patterns.

Test Coverage

Frontend unit tests (Tabs.test.tsx): Excellent coverage with 8 new tests across two describe blocks:

  • CSS key class: positive (valid blockId) and negative (empty blockId) — good.
  • Passive state persistence: reads stored state on mount, fallback for invalid label, stores on click, no persistence for unkeyed, no persistence for dynamic, default when no stored state, rerender with fresh node. Thorough coverage of both positive and negative cases.

Frontend unit tests (RenderNodeVisitor.test.tsx): Added test for blockWithId keying and renamed existing test for clarity. The test verifies that blocks with IDs use the ID as key, while blocks without IDs use positional index.

E2E tests (st_tabs_test.py):

  • CSS key class test uses get_element_by_key and regex matching — follows best practices.
  • Persistence test covers toggle on/off with wait_for_app_run via click_toggle — correct async handling.
  • Tab count updated from 13 to 14 to reflect the new test tabs added to the app script.

Minor gap: The PR description mentions "Unkeyed tabs reset to the default tab on remount" as an E2E test, but no such E2E test exists. The unit tests do cover the "no persistence for unkeyed" case (does NOT persist state when no blockId is set), which provides adequate coverage. Adding an explicit E2E test for the unkeyed case would increase confidence but is not strictly necessary given the unit test coverage.

Backwards Compatibility

No breaking changes identified.

  • The CSS key class is purely additive — tabs that don't have a key continue to have only the stTabs class.
  • State persistence only activates when shouldPersist is true (keyed + non-dynamic), leaving existing behavior unchanged for unkeyed or dynamic tabs.
  • The RenderNodeVisitor key change only affects blocks with a non-empty deltaBlock.id. Unkeyed blocks continue to use positional index as the key. The || undefined guard ensures empty strings are treated as "no id."
  • The onChange handler continues to call setStringValue for dynamic tabs exactly as before; the persistence branch is separate and mutually exclusive (shouldPersist = !isDynamic).

Security & Risk

No security concerns identified.

  • No new external requests, dependencies, or network calls.
  • classNames is an existing dependency, not newly introduced.
  • elementStates is an in-memory Map within WidgetStateManager — no browser storage (localStorage/sessionStorage/cookies) is involved, eliminating cross-origin or third-party context concerns.
  • Tab labels stored in elementStates are user-authored strings from the app script; they are not rendered via dangerouslySetInnerHTML or otherwise injected unsafely.
  • The RenderNodeVisitor key change uses node.deltaBlock.id, which follows the $$ID-hash-key format and is expected to be unique per keyed block.

Low regression risk: The RenderNodeVisitor change has the broadest blast radius since it affects all keyed block nodes (not just tabs). However, the change is conservative — it only activates when deltaBlock.id is a non-empty string, and the fallback to positional index is preserved. The parent PR (feature/passive-keyed-block-identity) controls when deltaBlock.id is set, limiting the scope.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • Tabs.tsx: Uses in-memory widgetMgr.elementStates (not browser storage APIs), no network or cross-origin interaction.
    • RenderNodeVisitor.tsx: Pure React key selection logic, no external system interaction.
    • st_tabs.py / st_tabs_test.py: E2E test changes are local app script additions with standard locators.
  • Suggested external_test focus areas: None required.
  • Confidence: High
  • Assumptions and gaps: The elementStates mechanism is entirely in-memory within the frontend runtime. If a future change persists elementStates to browser storage, external test coverage would be warranted.

Accessibility

No accessibility concerns. The changes do not affect:

  • ARIA attributes (tabs continue to use proper role="tab", aria-selected, etc. via baseui)
  • Keyboard navigation (tab activation behavior is unchanged)
  • Screen reader behavior
  • Focus management

The added CSS class (st-key-*) is a styling/testing hook and has no impact on assistive technology.

Recommendations

  1. Consider extracting a helper for the repeated stored-state read pattern in Tabs.tsx. A small function like getPersistedTabIndex(widgetMgr, blockId, allTabLabels) could reduce the three duplicate read-and-validate blocks to single calls, improving maintainability. This is a non-blocking suggestion for a follow-up.

  2. Add an E2E test for unkeyed tabs resetting on remount to match the PR description's claim. The app script already has the conditional toggle infrastructure; adding a simple test verifying that unkeyed tabs (without key=) reset to the default after a layout shift would complete the coverage story. This is a nice-to-have, not a blocker.

  3. Consider adding wait_for_app_run explicitly after the initial tab click in test_keyed_tabs_persist_active_tab_across_remount (line 318 of st_tabs_test.py). Since keyed passive tabs don't trigger a rerun (no on_change), this isn't strictly necessary — but adding a brief stability wait (or an explicit expect for the tab panel content visibility) before clicking the toggle would further reduce potential flakiness.

Verdict

APPROVED: Well-implemented feature with thorough test coverage, clean integration with existing patterns, no backwards compatibility or security concerns. The duplicated reconciliation pattern is a pragmatic choice given the existing architecture and can be addressed in a follow-up refactor.


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

@sfc-gh-lwilby sfc-gh-lwilby changed the title feat: st.tabs state persistence and CSS key class [feat] st.tabs state persistence and CSS key class Mar 12, 2026
@sfc-gh-lwilby sfc-gh-lwilby changed the title [feat] st.tabs state persistence and CSS key class [feat] st.tabs state persistence and CSS key class Mar 12, 2026
@sfc-gh-lwilby sfc-gh-lwilby changed the title [feat] st.tabs state persistence and CSS key class [feature] st.tabs state persistence and CSS key class Mar 12, 2026
@sfc-gh-lwilby sfc-gh-lwilby marked this pull request as ready for review March 13, 2026 15:45
@sfc-gh-lwilby sfc-gh-lwilby marked this pull request as draft March 13, 2026 16:37
@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 decreased by 0.0100%

  • Current PR: 87.7600% (14690 lines, 1797 missed)
  • Latest develop: 87.7700% (14655 lines, 1791 missed)

✅ Coverage change is within normal range.

📊 View detailed coverage comparison

@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from feature/passive-keyed-block-identity to graphite-base/14332 March 17, 2026 14:25
@sfc-gh-lwilby sfc-gh-lwilby marked this pull request as ready for review March 17, 2026 14:46
@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 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 implements state persistence and CSS key class application for st.tabs containers. The changes span two core frontend files:

  1. RenderNodeVisitor.tsx: Uses node.deltaBlock.id as the React key for block nodes when available, preserving component identity across positional shifts (e.g., when a conditional element above causes a delta path change). Falls back to positional index for unkeyed blocks.

  2. Tabs.tsx: For passively keyed tabs (have a key but no on_change="rerun"), persists the active tab label to elementStates via widgetMgr.setElementState/getElementState. Restores the previously active tab on mount if still valid. Applies the st-key-* CSS class to the outermost StyledTabContainer.

Frontend unit tests (Tabs, RenderNodeVisitor) and E2E Playwright tests are added covering the new behavior.

Code Quality

Consensus (3/3 reviewers agree): The code is clean, well-structured, and follows existing Streamlit frontend patterns. Specific positives noted:

  • The isPassivelyKeyed derivation is computed during render (not in an effect), correctly following the "derive during render" principle.
  • The getPersistedTabIndex helper is properly extracted as a module-level function with JSDoc.
  • The node.deltaBlock?.id || undefined conversion correctly handles protobuf's empty-string default.
  • The vi.mock("~lib/WidgetStateManager") was replaced with real WidgetStateManager instances in tests, improving test fidelity.
  • The classNames utility is used correctly for conditional CSS class application.

Maintainability observation (2/3 reviewers agree): The reconciliation logic for passively keyed tabs is duplicated across two useEffect blocks — the [allTabLabels] effect (line ~188) and the [node.children.length, updateScrollState] effect (line ~230). Both perform identical persisted-state restoration with the same fallback logic. While correct (the effects fire on different triggers), this duplication increases future drift risk. This is a pre-existing pattern that the PR extends consistently, so it is acceptable here but worth a follow-up refactor.

Test Coverage

Consensus (3/3 reviewers agree): Test coverage is excellent/strong.

  • Frontend unit tests (Tabs.test.tsx): Comprehensive coverage including CSS key class application (positive and negative), state persistence on mount, fallback to default for invalid stored labels, tab click persistence, non-persistence for unkeyed tabs, non-persistence for dynamic tabs, default behavior with no stored state, and persistence across rerender with fresh node references.
  • RenderNodeVisitor.test.tsx: Covers keyed blocks (using blockWithId) and unkeyed blocks with appropriate assertions.
  • E2E tests (st_tabs_test.py): Validates CSS key class presence via get_element_by_key and state persistence across remount caused by toggling a conditional element (bidirectional). Tab count assertion correctly updated from 13 to 14.

Minor gap (2/3 reviewers note): No E2E negative test for unkeyed tabs resetting on remount is present, though this scenario is covered at the unit test level. This is non-blocking.

Backwards Compatibility

Consensus (3/3 reviewers agree): Fully backwards compatible. No breaking API changes:

  • CSS key class only added when a key is provided; unkeyed tabs unaffected.
  • State persistence only applies to passively keyed tabs; dynamic tabs and unkeyed tabs retain existing behavior.
  • React key change in RenderNodeVisitor only applies when deltaBlock.id is set; unkeyed blocks continue using positional index.

Security & Risk

Consensus (3/3 reviewers agree): No security concerns. State persistence uses the frontend's in-memory elementStates Map in WidgetStateManager, not localStorage, sessionStorage, or cookies. No changes to authentication, session management, server endpoints, routing, or security headers. CSS class generation uses the existing convertKeyToClassName utility which safely sanitizes the key.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • Changes are confined to frontend React component state management (Tabs.tsx, RenderNodeVisitor.tsx) and test additions.
    • No routing, auth, websocket, embedding, asset, cross-origin, or security header changes.
    • State persistence uses in-memory elementStates Map, not browser storage APIs.
  • Confidence: High (3/3 reviewers agree)
  • Assumptions and gaps: None

Accessibility

Consensus (3/3 reviewers agree): No accessibility concerns. The underlying HTML structure and ARIA attributes (tab/tabpanel roles, aria-selected) are unchanged. The st-key-* CSS class does not affect ARIA semantics or keyboard navigation. No new interactive elements are introduced.

Reviewer Agreement Summary

Aspect gemini-3.1-pro gpt-5.3-codex-high opus-4.6-thinking
Verdict APPROVED APPROVED APPROVED
Code Quality Clean, well-commented Follows patterns, no blocking issues Well-structured, follows conventions
Test Coverage Excellent Strong Excellent
Backwards Compat No breaking changes No breaking changes Fully compatible
Security No concerns No concerns No concerns
External Test No No No
Duplicated effects Not flagged Flagged (non-blocking) Flagged (non-blocking)
Missing E2E negative test Not flagged Flagged (non-blocking) Flagged (non-blocking)

No disagreements were found across the three reviews. All reviewers unanimously approved with consistent assessments.

Recommendations

  1. (Non-blocking) Consider deduplicating the reconciliation logic in the two useEffect blocks in Tabs.tsx into a shared helper to reduce future drift risk. This is a pre-existing pattern and could be addressed in a follow-up.
  2. (Non-blocking) Consider adding an E2E negative test verifying that unkeyed tabs reset to default after a layout shift, complementing the existing unit test coverage of this scenario.

Verdict

APPROVED: All three reviewers unanimously approve. The implementation is well-scoped, follows existing patterns, has comprehensive test coverage (unit + E2E), maintains full backwards compatibility, and introduces no security or accessibility concerns. The two non-blocking recommendations (deduplicating effects, adding an E2E negative test) are minor maintainability suggestions for follow-up work.


Consolidated review by opus-4.6-thinking. This review synthesizes assessments from 3/3 expected models.


📋 Review by `gemini-3.1-pro`

Summary

The PR implements state persistence and CSS key class application for st.tabs containers. It uses the block id as the React key in RenderNodeVisitor to maintain component identity across positional shifts, and uses widgetMgr.getElementState/setElementState to persist the active tab for passively keyed tabs (tabs with a key but no on_change callback) across unmounts/remounts. It also applies the st-key-* CSS class to keyed tabs.

Code Quality

The code is clean, well-commented, and follows existing patterns. The logic for determining isPassivelyKeyed is sound, and the fallback mechanisms for when a persisted tab is no longer in the list are correct. The use of node.deltaBlock?.id as the React key in RenderNodeVisitor is an elegant solution to preserve component identity and state during layout shifts.

Test Coverage

Test coverage is excellent.

  • E2E Tests: A new test file st_tabs_state_persistence.py and corresponding Playwright tests verify that keyed tabs persist their active tab across remounts caused by layout shifts, and that the st-key-* CSS class is applied.
  • Frontend Unit Tests: Comprehensive tests added to Tabs.test.tsx cover the CSS key class application, state persistence on mount, fallback behavior, tab click persistence, and non-persistence for unkeyed/dynamic tabs. RenderNodeVisitor.test.tsx also includes a test for the new block keying behavior.

Backwards Compatibility

There are no breaking changes. The changes enhance the behavior of keyed st.tabs by preserving their state during layout shifts and remounts, which is the expected behavior for keyed elements. Unkeyed tabs and dynamic tabs (on_change="rerun") retain their existing behavior.

Security & Risk

No security concerns identified. The state persistence uses the frontend's in-memory WidgetStateManager (elementStates), not persistent storage like localStorage or cookies. The CSS class generation uses the existing convertKeyToClassName utility which safely sanitizes the key.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • The changes are confined to frontend React component state management (Tabs.tsx, RenderNodeVisitor.tsx) and E2E test additions.
    • No changes to routing, auth, websockets, embedding, assets, cross-origin behavior, or security headers.
  • Confidence: High
  • Assumptions and gaps: None

Accessibility

The PR does not alter the underlying HTML structure or ARIA attributes of the tabs (which are handled by the baseui tabs component). The addition of the st-key-* CSS class does not impact accessibility.

Recommendations

None. The implementation is robust and well-tested.

Verdict

APPROVED: The PR successfully implements state persistence and CSS key class application for passively keyed st.tabs with excellent test coverage and no identified 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 adds passive state persistence for keyed st.tabs, applies st-key-* CSS classes to the tabs container, and stabilizes block rendering identity by preferring deltaBlock.id as the React key for block nodes. It also extends frontend unit tests and existing st_tabs e2e coverage to validate persistence and CSS key-class behavior.

Code Quality

The implementation follows existing Streamlit frontend patterns and keeps dynamic tabs (on_change="rerun") separate from passive keyed tabs, which avoids mixing server-managed and frontend-managed state paths.

No blocking code-quality issues found.

Non-blocking maintainability note:

  • frontend/lib/src/components/elements/Tabs/Tabs.tsx has duplicated reconciliation logic in two effects (useEffect blocks around allTabLabels and node.children.length). It is correct as written, but consolidating the shared fallback/persisted-resolution path would reduce future drift risk.

Test Coverage

Coverage is strong for the changed behavior:

  • Frontend unit tests in frontend/lib/src/components/elements/Tabs/Tabs.test.tsx cover restore-on-mount, invalid stored-label fallback, click persistence, dynamic-tab exclusion, and rerender persistence.
  • frontend/lib/src/components/core/Block/RenderNodeVisitor.test.tsx adds explicit keying assertions for block nodes with/without IDs.
  • E2E updates in e2e_playwright/st_tabs.py and e2e_playwright/st_tabs_test.py validate keyed CSS class presence and persistence across reruns caused by layout shifts.

Minor gap (non-blocking): there is no explicit e2e assertion for the inverse remount case (unkeyed tabs resetting to default after remount), though this is covered at unit level.

Backwards Compatibility

No breaking API changes are introduced. Existing dynamic tab behavior remains covered and guarded (widgetId path still uses setStringValue), while keyed passive tabs gain deterministic persistence. Existing unkeyed tabs behavior appears unchanged.

Security & Risk

No direct security-sensitive surfaces were modified (no auth/session transport, server routes, asset handlers, cookies/XSRF/CORS, external fetches, or code-execution primitives). Risk is primarily UI state behavior and React reconciliation; regression risk appears low and is mitigated by both unit and e2e coverage.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • frontend/lib/src/components/elements/Tabs/Tabs.tsx: frontend-only tab-selection persistence via WidgetStateManager element state; no cookies/localStorage/sessionStorage or cross-origin logic.
    • frontend/lib/src/components/core/Block/RenderNodeVisitor.tsx: React keying change for block identity; no routing/auth/websocket/embedding/network/security-header changes.
    • e2e_playwright/st_tabs_test.py: local app behavior assertions only; no external host/iframe boundary mechanics introduced.
  • Suggested external_test focus areas:
    • None required for this PR based on touched surfaces.
    • Optional future enhancement: if tabs persistence is later coupled to browser storage or embed-specific behavior, add external coverage then.
  • Confidence: High
  • Assumptions and gaps: Assessment is based on the six changed files in this PR and current AGENTS/skill guidance; no hidden generated/runtime artifacts were reviewed.

Accessibility

No new accessibility regressions identified in the changed code. Interaction remains through BaseWeb tabs/roles, and the added CSS class/state logic does not alter semantic roles, names, or keyboard interaction patterns.

Recommendations

  1. Consider adding one e2e inverse-case check for unkeyed tabs resetting on remount to complement the keyed persistence e2e path.
  2. Consider refactoring the duplicated tab-reconciliation effects in Tabs.tsx into a shared helper to simplify long-term maintenance.

Verdict

APPROVED: The changes are well-scoped, backwards compatible, and adequately covered by unit + e2e tests, with no blocking security or regression concerns found.


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 implements state persistence and CSS key class application for st.tabs containers. The two core changes are:

  1. RenderNodeVisitor.tsx: Uses node.deltaBlock.id as the React key for block nodes when available, so keyed containers maintain component identity across positional shifts (e.g., when a conditional element above causes a delta path change). Falls back to positional index for unkeyed blocks.

  2. Tabs.tsx: When st.tabs has a key and is passive (non-dynamic, no on_change="rerun"), persists the active tab label to elementStates via widgetMgr.setElementState/getElementState. On mount, restores the previously active tab if it's still valid. Applies the st-key-* CSS class to the outermost StyledTabContainer.

Both frontend unit tests and E2E tests are added covering the new behavior.

Code Quality

The code is well-structured and follows existing Streamlit patterns:

  • The getPersistedTabIndex helper is properly extracted as a module-level function with a JSDoc comment, following the project's convention for static data structures and helper functions.
  • The isPassivelyKeyed derivation is computed during render (not in an effect), correctly following the "derive during render" principle from frontend/AGENTS.md.
  • The vi.mock("~lib/WidgetStateManager") was removed from Tabs.test.tsx and replaced with real WidgetStateManager instances via createWidgetMgr(). This improves test fidelity.
  • The classNames utility is used correctly for conditional CSS class application.
  • The node.deltaBlock?.id || undefined conversion in RenderNodeVisitor.tsx (line 89) correctly handles the protobuf default of empty string by converting it to undefined, allowing the fallback to positional index.

Minor observation: The reconciliation logic for passively keyed tabs is duplicated across two effects — the [allTabLabels] effect (line 188) and the [node.children.length, updateScrollState] effect (line 230). Both check isPassivelyKeyed and attempt to restore from persisted state with identical fallback logic. While not a bug (the effects fire on different triggers: label changes vs. structural changes), the duplication makes the component harder to reason about. This is a pre-existing pattern that the PR extends consistently, so it's acceptable here, but worth noting for a future refactor.

Test Coverage

Frontend unit tests (Tabs.test.tsx): Excellent coverage including:

  • CSS key class application (positive: with blockId; negative: without blockId)
  • State persistence on mount from stored state
  • Fallback to default when stored label is not in tab list
  • Active tab label persisted on tab click
  • Non-persistence when no blockId is set (negative case)
  • Non-persistence for dynamic (widget) tabs (negative case)
  • Default behavior when no stored state exists
  • Persistence across rerender with fresh node reference

RenderNodeVisitor tests: Properly covers both keyed blocks (using blockWithId) and unkeyed blocks (existing test updated with clearer name).

E2E tests (st_tabs_test.py): Good coverage of:

  • CSS key class verification via get_element_by_key
  • State persistence across remount caused by toggling a conditional element above the tabs (bidirectional: toggle on and off)

The existing tab count assertion was correctly updated from 13 to 14 to reflect the new tabs section in the app script.

The E2E test for unkeyed tabs resetting on remount (mentioned in the PR description) is not present in the actual test code. This negative case would strengthen confidence but is already covered by the frontend unit tests.

Backwards Compatibility

Fully backwards compatible:

  • CSS key class (st-key-*) is only added when a key is provided; unkeyed tabs are unaffected.
  • State persistence only applies to passively keyed tabs (blockId set, not dynamic). Existing tabs without keys and dynamic tabs are unaffected.
  • React key change in RenderNodeVisitor only applies when node.deltaBlock.id is set. Unkeyed blocks continue to use positional index.
  • No API changes or new parameters introduced.

Security & Risk

No security concerns identified:

  • Changes are purely frontend state management using the existing elementStates in-memory Map in WidgetStateManager.
  • No new external dependencies, network requests, or dynamic code evaluation.
  • No changes to authentication, session management, or server endpoints.
  • No XSS, injection, or path traversal risks.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • frontend/lib/src/components/elements/Tabs/Tabs.tsx: Frontend-only state persistence using in-memory elementStates Map (not localStorage/sessionStorage/cookies)
    • frontend/lib/src/components/core/Block/RenderNodeVisitor.tsx: React key selection logic change with no network, auth, or embedding impact
  • Suggested external_test focus areas: N/A
  • Confidence: High
  • Assumptions and gaps: None — all changes are purely frontend React component state management with no external system interaction

Accessibility

No accessibility concerns:

  • The existing tab accessibility (ARIA roles tab/tabpanel, aria-selected attributes) is unchanged.
  • The st-key-* CSS class is applied via classNames and does not affect ARIA semantics or keyboard navigation.
  • No new interactive elements are introduced.
  • The activateOnFocus behavior on the tabs is preserved.

Recommendations

  1. Consider deduplicating the reconciliation logic: The passively-keyed state restoration is repeated identically in the [allTabLabels] effect and the [node.children.length, updateScrollState] effect. Extracting this into a shared function or combining the effects could improve maintainability. This is a pre-existing issue that this PR extends, so it could be addressed in a follow-up.

  2. Consider adding an E2E negative test for unkeyed tabs: The PR description mentions "Unkeyed tabs reset to the default tab on remount" as a tested scenario, but the E2E test only covers keyed tabs persistence. While the unit tests cover the unkeyed case, an E2E negative assertion (e.g., verifying that unkeyed tabs reset after a layout shift) would round out the test suite. This is optional since the positive E2E test combined with unit test coverage provides sufficient confidence.

Verdict

APPROVED: Well-implemented feature with comprehensive test coverage, clean backwards compatibility, and no security or accessibility concerns. The state persistence mechanism correctly leverages existing elementStates infrastructure, and the React key stabilization via RenderNodeVisitor is a sound approach to maintaining component identity across positional shifts.


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

@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 changed the base branch from graphite-base/14332 to feature/passive-keyed-block-identity March 17, 2026 15:16
@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.29 KiB 97.29 KiB 0.00%

📊 View detailed bundle comparison

@github-actions
Copy link
Copy Markdown
Contributor

📈 Significant wheel size change detected

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

  • Current PR: 8886.57 KB
  • Latest develop: 8755.05 KB

Please verify that this change is expected.

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 👍

Base automatically changed from feature/passive-keyed-block-identity to develop March 19, 2026 07:02
sfc-gh-lwilby and others added 6 commits March 19, 2026 08:03
…osure from losing persisted tab

Co-authored-by: lawilby <[email protected]>
- Rename generic `stored` variable to `storedActiveTabLabel` across all
  four elementStates read sites for clarity
- Add explanatory comment on `shouldPersist` condition describing why
  both `blockId` and `!isDynamic` are required

Co-authored-by: lawilby <[email protected]>
- Rename `shouldPersist` to `isPassivelyKeyed` for clarity: describes
  tabs that have a key but are not dynamic widgets
- Extract `getPersistedTabIndex()` helper to deduplicate the 4 repeated
  read-and-validate blocks for stored tab state from elementStates
- Add explanatory comment on `isPassivelyKeyed` condition
- Rename local `stored` variables to `storedActiveTabLabel` within the
  helper for specificity

Co-authored-by: lawilby <[email protected]>
Replace vi.mock/vi.spyOn-based persistence tests with behavioral tests
that use real WidgetStateManager instances. Tests now pre-populate
elementStates and check component behavior (selected tab, stored values)
rather than asserting on internal getElementState/setElementState calls.

Co-authored-by: lawilby <[email protected]>
Copilot AI review requested due to automatic review settings March 19, 2026 07:03
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the feature/tabs-state-persistence branch from b3a3afe to 8cf2dee Compare March 19, 2026 07:03
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 frontend-backed state persistence and CSS key class support for st.tabs, and improves block React key stability so keyed containers keep identity across positional shifts.

Changes:

  • Persist/restore active tab selection for passive keyed st.tabs via WidgetStateManager elementStates; apply st-key-* CSS class to the outer tabs container.
  • Use node.deltaBlock.id as the React key for block nodes when available (fallback to positional index otherwise).
  • Add/extend frontend unit tests and extend the existing st_tabs Playwright E2E coverage for keyed-tab persistence + CSS class.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
frontend/lib/src/components/elements/Tabs/Tabs.tsx Persists/restores active tab label for passive keyed tabs; applies st-key-* class.
frontend/lib/src/components/elements/Tabs/Tabs.test.tsx Adds unit tests for CSS key class + passive persistence behaviors.
frontend/lib/src/components/core/Block/RenderNodeVisitor.tsx Uses deltaBlock.id as React key for block nodes when present.
frontend/lib/src/components/core/Block/RenderNodeVisitor.test.tsx Adds coverage for block keying behavior.
e2e_playwright/st_tabs.py Adds a keyed-tabs scenario that shifts layout above tabs to validate persistence.
e2e_playwright/st_tabs_test.py Updates snapshot count and adds E2E assertions for keyed tabs CSS key class + persistence across layout shifts.

Comment on lines +61 to +66
const stored = widgetMgr.getElementState(blockId, "activeTabLabel") as
| string
| undefined
if (!stored) return null
const idx = allTabLabels.indexOf(stored)
return idx >= 0 ? { index: idx, label: stored } : null
Comment on lines +305 to +315
def test_keyed_tabs_css_key_class(app: Page):
"""Keyed tabs should have the st-key-* CSS class on the outermost element."""
keyed_tabs = get_element_by_key(app, "key_only_tabs")
expect(keyed_tabs).to_have_class(re.compile(r"st-key-key_only_tabs"))


def test_keyed_tabs_persist_active_tab_across_remount(app: Page):
"""Toggling a conditional element above keyed tabs shifts the delta path,
but the active tab should be preserved via elementStates.
"""
keyed_tabs = get_element_by_key(app, "persist_tabs")
…stedTabIndex

Fixes @typescript-eslint/no-unnecessary-type-assertion lint error by
passing <string> to getElementState instead of casting the result.

Made-with: Cursor

Co-authored-by: lawilby <[email protected]>
@sfc-gh-lwilby sfc-gh-lwilby merged commit 3c69953 into develop Mar 19, 2026
44 checks passed
@sfc-gh-lwilby sfc-gh-lwilby deleted the feature/tabs-state-persistence branch March 19, 2026 07:39
sfc-gh-lwilby added a commit that referenced this pull request Mar 20, 2026
…class (#14356)

## 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

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

**Depends on:** #14354

---------

Co-authored-by: lawilby <[email protected]>
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