Skip to content

[DynamicContainers] Dynamic Tabs#13910

Merged
sfc-gh-lwilby merged 1 commit intodevelopfrom
02-11-_dynamiccontainers_dynamic_tabs
Feb 18, 2026
Merged

[DynamicContainers] Dynamic Tabs#13910
sfc-gh-lwilby merged 1 commit intodevelopfrom
02-11-_dynamiccontainers_dynamic_tabs

Conversation

@sfc-gh-lwilby
Copy link
Copy Markdown
Collaborator

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

Describe your changes

Test Demo App: https://dynamic-tabs.streamlit.app/

Stacked on #13867.

Adds key and on_change parameters to st.tabs, enabling stateful, lazily-executed tabs. When on_change="rerun", switching tabs triggers a rerun and each tab's .open property returns True for the active tab and False for inactive ones. This lets apps skip expensive work in hidden tabs:

tabs = st.tabs(["Sales", "Customers"], on_change="rerun")

if tabs[0].open:
    with tabs[0]:
        expensive_sales_query()

if tabs[1].open:
    with tabs[1]:
        expensive_customer_query()

Programmatic tab switching is supported via st.session_state:

def goto_settings():
    st.session_state.my_tabs = "Settings"

st.button("Go to Settings", on_click=goto_settings)
tabs = st.tabs(["Home", "Settings"], key="my_tabs", on_change="rerun")

Backend

  • Adds key and on_change parameters to st.tabs().
  • When on_change="rerun", registers tabs as a widget via register_widget, tracking the active tab label as a string value. Sets .open = True on the active TabContainer and .open = False on the rest. Without on_change, .open remains None.
  • Falls back to the default tab when the stored label is no longer in the tab list.
  • Note: st-key- CSS class support for tabs is deferred to a follow-up PR to ensure the class is applied to the correct DOM element.

Frontend

  • On tab switch in dynamic tabs, sends the new tab label to the backend via widgetMgr.setStringValue.
  • Syncs the selected tab when defaultTabIndex changes from the backend (programmatic control).
  • Passes fragmentId to the Tabs component for correct fragment-scoped widget updates.

GitHub Issue Link (if applicable)

#6004

Testing Plan

Python unit tests (lib/tests/streamlit/elements/layouts_test.py): 8 tests covering invalid on_change values, .open state with and without default, session state accessibility, and correct proto tab index.

Frontend unit tests (frontend/lib/src/components/elements/Tabs/Tabs.test.tsx): Tests for dynamic tab behavior including widget state updates on tab switch and programmatic sync from backend.

E2E tests (e2e_playwright/st_tabs_test.py): test_dynamic_tabs_lazy_execution verifies only the active tab's content executes on each rerun; test_dynamic_tabs_programmatic_control verifies switching tabs via session state buttons.


Contribution License Agreement

By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.

Copy link
Copy Markdown
Collaborator Author

sfc-gh-lwilby commented Feb 11, 2026

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 11, 2026

✅ PR preview is ready!

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

@sfc-gh-lwilby sfc-gh-lwilby added ai-review If applied to PR or issue will run AI review workflow security-assessment-completed change:feature PR contains new feature or enhancement implementation impact:users PR changes affect end users labels Feb 11, 2026
@snyk-io
Copy link
Copy Markdown
Contributor

snyk-io bot commented Feb 11, 2026

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

Status Scanner Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

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

Summary

This PR adds dynamic tabs functionality to st.tabs() via new key and on_change parameters. When on_change="rerun" is set, Streamlit reruns the app on tab switch, enabling lazy execution of tab content through the .open property on each tab. The implementation spans backend (Python widget registration and state management), frontend (widget state syncing and programmatic tab control), and tests (Python unit tests and E2E playwright tests).

Changed files:

  • lib/streamlit/elements/layouts.py — New key/on_change params, _TabsSerde, widget registration
  • frontend/lib/src/components/elements/Tabs/Tabs.tsx — Widget state syncing, CSS class support, programmatic control
  • frontend/lib/src/components/core/Block/Block.tsx — Pass fragmentId to Tabs
  • e2e_playwright/st_tabs.py / st_tabs_test.py — E2E test app and tests
  • lib/tests/streamlit/elements/layouts_test.py — Python unit tests

Code Quality

Backend (Python):
The backend implementation is clean and well-structured. The _TabsSerde dataclass follows the existing serialization pattern. Input validation is solid, and the widget registration logic is clear. Good defensive coding with the current_tab_label not in tabs fallback (line 814).

Frontend (TypeScript):
The changes are mostly well-integrated with the existing Tabs component. Two concerns:

  1. useEffect for programmatic tab sync (lines 98–112 in Tabs.tsx): The activeTabKey is included in the dependency array but is only read for a guard condition (defaultTabIndex !== activeTabKey). This means the effect body re-executes on every user tab click even though it will be a no-op (because defaultTabIndex hasn't changed). While not a bug, this is wasteful. Consider removing activeTabKey from the dependency array with an ESLint suppression comment, or restructuring the logic to derive the state during render instead of in an effect.

  2. Stale docstring (lines 606–611 in layouts.py): The .. note:: block still says "Tabs do not currently support conditional rendering." This is now inaccurate since on_change="rerun" enables exactly that. The docstring should be updated to reflect the new capability.

Test Coverage

Python unit tests (9 new tests): Good coverage of:

  • Invalid on_change values
  • .open state with on_change="rerun" (with and without default)
  • Block ID setting (stateful vs non-stateful)
  • Session state integration
  • Proto tab index correctness

E2E tests (2 new tests): Good coverage of:

  • Lazy execution (only active tab's content runs)
  • Programmatic tab control via session state
  • Negative assertions (inactive tab content not visible)

Missing frontend unit tests: No new tests were added to Tabs.test.tsx. This is a significant gap for a feature that introduces:

  • Widget state management (widgetMgr.setStringValue on tab change)
  • Programmatic tab switching via defaultTabIndex changes
  • CSS class name from key prop (convertKeyToClassName)
  • The isDynamic code path behavior

Frontend unit tests should be added at minimum for:

  • Verifying widgetMgr.setStringValue is called when a dynamic tab is clicked
  • Verifying widgetMgr.setStringValue is NOT called for non-dynamic tabs
  • Verifying the CSS class from key is applied
  • Verifying programmatic tab switching when defaultTabIndex changes

Missing Python tests:

  • No test for on_change="ignore" explicitly verifying .open is None (though this is implicitly covered by the default behavior tests, an explicit test would be clearer)
  • No test verifying that key without on_change does NOT set .open or register a widget

Backwards Compatibility

The changes are mostly backwards compatible:

  • Existing st.tabs(["A", "B"]) calls continue to work identically (no state tracking, .open is None)
  • New parameters (key, on_change) are keyword-only with safe defaults (None)
  • The on_change="ignore" path correctly falls back to non-stateful behavior

Potential issue — key without on_change triggers unintended reruns:
When key is provided without on_change (e.g., st.tabs(["A", "B"], key="my_tabs")):

  • Backend sets block_proto.id = element_id (for CSS class generation) but does NOT call register_widget
  • Frontend sees non-empty widgetId → sets isDynamic = true
  • User clicks a tab → widgetMgr.setStringValue(...) is called with { fromUi: true } → triggers a backend rerun
  • Backend reruns but has no registered widget for this ID, so the state update is ignored

This causes unnecessary reruns whenever a user switches tabs with key but no on_change. Users providing key only for CSS styling would experience unexpected performance degradation. The frontend should differentiate between "has a key for CSS" and "is a dynamic widget." One approach: only call setStringValue when a dedicated flag (e.g., a isDynamic or isStateful field in the protobuf) is set, rather than inferring dynamism from the presence of a widget ID.

Security & Risk

  • No security concerns identified. The widget state stores only tab labels (strings) that the developer already defines.
  • The _TabsSerde.deserialize correctly validates against None and falls back to the default.
  • The current_tab_label not in tabs validation prevents stale state from breaking the UI.
  • Low regression risk for existing non-dynamic tabs since the new code paths are gated behind is_stateful and isDynamic checks.

Accessibility

  • Tab accessibility is handled by the BaseUI Tabs component (tabs-motion), which provides ARIA roles, keyboard navigation, and aria-selected attributes.
  • No new accessibility concerns introduced. The dynamic behavior is transparent to assistive technology — tab switching still works via standard ARIA tab patterns.
  • The activateOnFocus prop is maintained, preserving keyboard navigation.

Recommendations

  1. [Bug] Fix key-without-on_change causing unintended reruns. The frontend should not call widgetMgr.setStringValue when the tabs are not actually registered as a widget. Consider adding a boolean field to the proto (e.g., is_dynamic on TabContainer) or checking for widget registration on the frontend, rather than inferring dynamism from Boolean(widgetId).

  2. [Testing] Add frontend unit tests in Tabs.test.tsx. At minimum, test:

    • widgetMgr.setStringValue is called on tab click for dynamic tabs
    • widgetMgr.setStringValue is NOT called for non-dynamic tabs
    • CSS class from key is applied to the container
    • Programmatic tab switching when defaultTabIndex prop changes
  3. [Docs] Update the stale docstring note at layouts.py lines 606–611. The note currently states tabs don't support conditional rendering, but on_change="rerun" now enables it. Update it to mention the new capability, e.g.:

    "By default, all tab content is computed and sent to the frontend. Use on_change="rerun" to enable lazy execution, where only the active tab's content runs."

  4. [Minor] Consider removing activeTabKey from the useEffect dependency array (lines 98–112 in Tabs.tsx) to avoid unnecessary effect re-executions on every user tab click. The guard defaultTabIndex !== prevDefaultTabIndexRef.current already prevents incorrect behavior.

  5. [Minor] Consider adding a test for on_change="ignore" with key to explicitly verify that .open remains None and no widget state is registered, documenting the expected behavior of this combination.

Verdict

CHANGES REQUESTED: The key parameter without on_change causes unintended reruns due to the frontend treating any non-empty widgetId as a dynamic widget. This is a user-facing bug that should be fixed before merge. Additionally, frontend unit tests are missing for significant new behavior.


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

@github-actions github-actions bot added the do-not-merge PR is blocked from merging label Feb 11, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 11, 2026

📈 Frontend coverage change detected

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

  • Current PR: 87.1200% (14122 lines, 1818 missed)
  • Latest develop: 87.1000% (14110 lines, 1820 missed)

✅ Coverage change is within normal range.

📊 View detailed coverage comparison

@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-11-_dynamiccontainers_dynamic_tabs branch from 0f4ce6f to 76a4107 Compare February 11, 2026 16:03
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-11-_dynamiccontainers_dynamic_tabs branch from 76a4107 to 70f0fde Compare February 11, 2026 17:04
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-09-_dynamiccontainers_foundational_work_adding_.open_properites branch from bd695b7 to a74035b Compare February 11, 2026 17:05
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-11-_dynamiccontainers_dynamic_tabs branch from 70f0fde to 4c1d24d Compare February 11, 2026 17:05
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-09-_dynamiccontainers_foundational_work_adding_.open_properites branch from a74035b to ec3f270 Compare February 11, 2026 17:17
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-11-_dynamiccontainers_dynamic_tabs branch 2 times, most recently from e4574bd to e6191a1 Compare February 11, 2026 18:05
@sfc-gh-lwilby sfc-gh-lwilby added the ai-review If applied to PR or issue will run AI review workflow label Feb 11, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Feb 11, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR adds key and on_change parameters to st.tabs(), enabling stateful, lazily-executed tabs. When on_change="rerun", switching tabs triggers a full rerun and each tab's .open property reflects whether it is the active tab, allowing users to skip expensive computation in hidden tabs. Programmatic tab switching via st.session_state is also supported. When only key is provided (no on_change), a CSS class is applied for styling without registering widget state.

Key changes:

  • Protobuf: Adds optional string widget_id field to TabContainer in Block.proto.
  • Backend: Adds key/on_change parameters to st.tabs(), widget registration via register_widget, a _TabsSerde dataclass, and .open state management.
  • Frontend: Passes fragmentId to the Tabs component, sends tab label to backend via widgetMgr.setStringValue on tab switch for dynamic tabs, syncs active tab on programmatic defaultTabIndex changes, and applies st-key- CSS class.
  • Tests: 10 new Python unit tests, 3 new E2E tests, and 6 new frontend unit tests.

Code Quality

The code is well-structured, clean, and follows the established patterns in the codebase.

Backend (lib/streamlit/elements/layouts.py):

  • The _TabsSerde dataclass is simple and correct. The @dataclass decorator is used appropriately.
  • The is_stateful flag cleanly separates stateful from non-stateful code paths.
  • Label validation at line 814 handles the edge case where widget state refers to a removed tab label.
  • The check_widget_policies call correctly passes on_change=None since the tab's on_change is a string, not a callback — callbacks are not yet supported, and this is a reasonable MVP design.
  • The three-way code path (stateful / key-only / neither) at lines 779-822 is clear and well-documented.

Frontend (Tabs.tsx):

  • The allTabLabels memoization (line 74-81) prevents unnecessary effect re-runs, following best practices.
  • The useEffect at line 100-114 for syncing programmatic tab changes is appropriate: it synchronizes with an external system (backend state changes), which is a valid use case per the AGENTS.md effect guidelines. The prevDefaultTabIndexRef pattern correctly detects actual changes.
  • The onChange handler (line 164-177) cleanly separates local UI state updates from widget state updates, guarding the latter behind isDynamic.
  • The CSS key class is derived from block.id (line 150), which works for both key-only and widget modes.

Minor observations:

  1. In e2e_playwright/st_tabs.py line 149, the comment for the key-only section is missing a blank line separator from the previous code block:
        st.write("Gamma tab content")
# Key-only (no on_change) — should NOT trigger reruns on tab switch

This is a minor formatting issue that doesn't affect functionality.

  1. The typing test file (lib/tests/streamlit/typing/tab_container_types.py) could benefit from a type assertion for the new parameters (e.g., tabs(["A", "B"], key="k", on_change="rerun")), but the existing assertions already cover the return type and .open property correctly.

Test Coverage

Test coverage is thorough and well-designed:

Python unit tests (10 new tests in layouts_test.py):

  • Invalid on_change value validation
  • .open state with and without default
  • Proto field presence for id and widget_id across all three modes
  • Session state accessibility with key
  • Correct default_tab_index in proto
  • on_change="ignore" producing no state tracking (good anti-regression check)

Frontend unit tests (6 new tests in Tabs.test.tsx):

  • widgetMgr.setStringValue called on tab click for dynamic tabs
  • widgetMgr.setStringValue NOT called for non-dynamic tabs (anti-regression)
  • Programmatic tab sync via rerender with updated defaultTabIndex
  • CSS class applied with key (both with and without widget)
  • CSS class NOT applied without key (anti-regression)

E2E tests (3 new tests in st_tabs_test.py):

  • Lazy execution: verifies execution counts across tab switches
  • Programmatic control: verifies session state button control
  • Key-only mode: verifies no rerun on tab switch (good anti-regression)

The E2E tests correctly use wait_for_app_run(app) after clicks that should trigger reruns, and correctly omit it for key-only tabs where no rerun should happen. The tests use expect for auto-wait assertions and follow the locator priority guidelines (role-based and text-based).

One note: the E2E tests don't include fragment-scoped testing (@st.fragment), which is listed as a recommended scenario in the E2E AGENTS.md. Since fragmentId is now being passed to the component, a fragment-scoped test would increase confidence. This could be a follow-up item.

Backwards Compatibility

This PR is fully backwards compatible:

  • Protobuf: The new optional string widget_id field (field 2) in TabContainer is additive. Existing messages without this field will have it absent, which the frontend correctly treats as non-dynamic behavior.
  • Python API: Both key and on_change parameters are optional keyword-only arguments with None defaults, preserving existing call signatures.
  • Default behavior: Without on_change, tabs behave identically to before — all tab content runs, .open returns None, and no widget state is registered.
  • Frontend: The isDynamic check (line 71) gates all new behavior. Without widgetId in the proto, the component behaves exactly as before.

Security & Risk

No security concerns identified:

  • The widget state contains only user-visible tab labels (strings), not sensitive data.
  • The on_change parameter is validated against a strict allowlist ({"ignore", "rerun"}) at line 766.
  • The check_widget_policies call ensures proper widget registration in the correct scope.
  • Label validation prevents stale widget state from referencing non-existent tabs.

Risk assessment: Low risk. The new functionality is cleanly gated behind the on_change="rerun" parameter. The non-dynamic path is unchanged.

Accessibility

No accessibility concerns. The PR does not modify any ARIA attributes, roles, or keyboard behavior. The tabs continue to use BaseUI's Tabs component with proper tab and tabpanel roles. The activateOnFocus prop remains enabled for keyboard navigation.

Recommendations

  1. Minor formatting: Add a blank line before the key-only section comment in e2e_playwright/st_tabs.py (line 149) for consistency:

    # Before:
            st.write("Gamma tab content")
    # Key-only (no on_change) — ...
    
    # After:
            st.write("Gamma tab content")
    
    # Key-only (no on_change) — ...
  2. Typing tests: Consider adding type assertions for the new parameters in lib/tests/streamlit/typing/tab_container_types.py:

    # With new parameters
    assert_type(tabs(["A", "B"], key="k", on_change="rerun"), Sequence[TabContainer])
    assert_type(tabs(["A", "B"], on_change="ignore"), Sequence[TabContainer])
  3. Follow-up consideration: A fragment-scoped E2E test (@st.fragment) would be valuable for validating the fragmentId integration. This could be done in a follow-up PR.

These are all minor suggestions and none are blocking.

Verdict

APPROVED: Well-designed, backwards-compatible feature addition with comprehensive test coverage, clean code paths, and proper separation between stateful and non-stateful tab behavior.


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

@github-actions github-actions bot removed the do-not-merge PR is blocked from merging label Feb 11, 2026
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-11-_dynamiccontainers_dynamic_tabs branch from e6191a1 to aebf31d Compare February 11, 2026 18:40
@sfc-gh-lwilby sfc-gh-lwilby added the ai-review If applied to PR or issue will run AI review workflow label Feb 12, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Feb 12, 2026
@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from 02-09-_dynamiccontainers_foundational_work_adding_.open_properites to graphite-base/13910 February 12, 2026 17:48
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-11-_dynamiccontainers_dynamic_tabs branch from aebf31d to 11e9015 Compare February 12, 2026 17:49
@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from graphite-base/13910 to 02-09-_dynamiccontainers_foundational_work_adding_.open_properites February 12, 2026 17:49
@sfc-gh-lwilby sfc-gh-lwilby marked this pull request as ready for review February 12, 2026 18:19
Base automatically changed from 02-09-_dynamiccontainers_foundational_work_adding_.open_properites to develop February 12, 2026 18:27
Copilot AI review requested due to automatic review settings February 13, 2026 10:40
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-11-_dynamiccontainers_dynamic_tabs branch from 11e9015 to f78bbec Compare February 13, 2026 10:40
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

This PR implements dynamic tabs for Streamlit, adding key and on_change parameters to st.tabs to enable stateful, lazily-executed tabs. When on_change="rerun" is set, switching tabs triggers a rerun and each tab's .open property indicates whether it's active, allowing developers to conditionally execute expensive operations only for visible tabs. The implementation also supports programmatic tab switching via session state.

Changes:

  • Adds key and on_change parameters to st.tabs() with validation for "ignore", "rerun", or None values
  • Implements widget registration for dynamic tabs tracking the active tab label as string state
  • Extends the TabContainer protobuf message with an optional widget_id field for widget state tracking
  • Updates frontend to send widget state updates on tab changes and sync with backend programmatic control
  • Includes comprehensive unit tests (Python and TypeScript) and E2E tests for lazy execution and programmatic control scenarios

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
proto/streamlit/proto/Block.proto Adds optional widget_id field to TabContainer message for dynamic tab state tracking
lib/streamlit/elements/layouts.py Implements key and on_change parameters with widget registration, serde, and .open property logic
lib/tests/streamlit/elements/layouts_test.py Adds 8 unit tests covering dynamic tab behavior, session state, widget ID proto, and edge cases
frontend/lib/src/components/elements/Tabs/Tabs.tsx Implements widget state updates on tab click, programmatic sync via defaultTabIndex effect, and CSS key class extraction (deferred feature)
frontend/lib/src/components/elements/Tabs/Tabs.test.tsx Adds tests for dynamic tab widget state updates and programmatic control synchronization
frontend/lib/src/components/core/Block/Block.tsx Passes fragmentId to Tabs component for fragment-scoped widget updates
e2e_playwright/st_tabs_test.py Adds E2E tests for lazy execution, programmatic control, and key-only (non-dynamic) behavior
e2e_playwright/st_tabs.py Test app demonstrating dynamic tabs with execution counters, programmatic switching, and key-only tabs

@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-11-_dynamiccontainers_dynamic_tabs branch 2 times, most recently from 88f803d to ed2aa5a Compare February 13, 2026 11:27
@github-actions
Copy link
Copy Markdown
Contributor

Consolidated Code Review: PR #13910 — [DynamicContainers] Dynamic Tabs

Summary

This PR adds key and on_change parameters to st.tabs(), enabling stateful, lazily-executed tabs. When on_change="rerun", switching tabs triggers a script rerun, and each tab's .open property indicates the active tab (True/False), allowing apps to skip expensive work in hidden tabs. Programmatic tab switching is supported via st.session_state.

Changes span the full stack:

  • Protobuf: Added optional string widget_id to TabContainer message.
  • Backend: New key/on_change parameters on st.tabs(), widget registration via register_widget, _TabsSerde dataclass, .open state management, and fallback logic for stale labels.
  • Frontend: Tabs.tsx reads the new widgetId, calls widgetMgr.setStringValue on tab switch for dynamic tabs, and syncs programmatic defaultTabIndex changes via a useEffect. Block.tsx passes fragmentId to the Tabs component.
  • Tests: 8 new Python unit tests, 3 new frontend unit tests, and 3 new E2E tests.

Reviewer Agreement

Both reviewers (gpt-5.3-codex-high and opus-4.6-thinking) agreed on:

  • Clean implementation following existing Streamlit patterns well
  • Good test coverage for core flows (lazy execution, programmatic control, key-only tabs, .open behavior, session state, proto fields)
  • Full backwards compatibility — default behavior is preserved, new params are optional with None defaults, protobuf field is optional
  • No security concerns — widget state only stores developer-provided tab labels
  • No accessibility regressions — continues using BaseWeb's UITabs with proper ARIA semantics
  • Duplicate tab labels are a known edge case with label-based identification in dynamic mode

Reviewer Disagreement

The key disagreement is on severity:

Issue gpt-5.3-codex-high opus-4.6-thinking Resolution
Duplicate tab labels in dynamic mode Blocking — selection ambiguity Non-blocking — consistent with existing default behavior See below
Stale session state on label fallback Blockingst.session_state[key] disagrees with rendered tab Not raised as issue — fallback logic called "well-handled" See below

Resolution: Duplicate Tab Labels

After verifying the code, tabs.index(current_tab_label) always resolves to the first occurrence — meaning selecting a later duplicate snaps back to the first on rerun. However, this is consistent with existing behavior: the default parameter already works via tabs.index(default), which has the same first-occurrence semantics. Duplicate tab labels have always been inherently ambiguous. This is a pre-existing limitation, not a regression introduced by this PR. A docstring note would be helpful but this is non-blocking.

Resolution: Stale Session State Fallback

After inspecting the code, there is a real (minor) inconsistency: when register_widget returns a stale label (e.g., "OldTab" from a previous run with different tabs), the local current_tab_label is corrected to the fallback, but st.session_state[key] retains the stale value until the next user interaction. However:

  1. This only occurs in the narrow edge case where tab labels change between reruns while stale state exists.
  2. The rendered tab will be correct (the fallback applies to the proto's default_tab_index).
  3. The inconsistency is transient — it resolves on the next tab switch.

This is a valid observation worth addressing in a follow-up, but it is not a blocking issue for initial merge.

Code Quality

The implementation is clean and well-structured:

  • Backend: _TabsSerde dataclass follows the established serde pattern. The is_stateful flag cleanly separates dynamic and non-dynamic paths. Good use of check_widget_policies and compute_and_register_element_id.
  • Frontend: The isDynamic flag based on widgetId presence is clean. The useMemo for allTabLabels is a good optimization over the previous mutable array approach. The useEffect for programmatic sync with prevDefaultTabIndexRef is acceptable.
  • Protobuf: optional string widget_id on TabContainer is well-designed, allowing the frontend to distinguish between "no widget" and "widget present."
  • Tests: All Python tests have docstrings. E2E tests use wait_for_app_run, stable locators, and include negative assertions.

Test Coverage

Strong coverage for core functionality. Both reviewers noted the following gaps:

  • No test for duplicate tab labels with on_change="rerun" (both reviewers)
  • No test asserting st.session_state[key] value after stale label fallback (gpt-5.3-codex-high)
  • No test for dynamic tabs inside @st.fragment (opus-4.6-thinking)
  • No typing tests in lib/tests/streamlit/typing/ for the new key and on_change parameters (opus-4.6-thinking)

Recommendations

  1. Document key behavior without on_change="rerun": When on_change is not "rerun", the key parameter currently has no effect (no widget registration, no session state). This should be noted in the docstring to avoid user confusion.

  2. Add a note about duplicate tab labels: A one-line docstring note that duplicate labels with on_change="rerun" always resolve to the first occurrence would help prevent surprise.

  3. Consider fixing the stale session state fallback (follow-up): When a stale label falls back to the default, also update the widget state so st.session_state[key] is consistent with the rendered tab in the same run.

  4. Add typing tests (follow-up): Consider adding a typing test file at lib/tests/streamlit/typing/tabs_types.py for the new key and on_change parameters.

  5. Test inside @st.fragment (follow-up): The fragmentId propagation via Block.tsx should be verified end-to-end with dynamic tabs inside a fragment.

Verdict

APPROVED — This is a well-implemented feature addition with clean code, solid test coverage, full backwards compatibility, and no security or accessibility concerns. The edge cases identified by reviewers (duplicate labels, stale session state fallback) are real but non-blocking: the duplicate label behavior is consistent with pre-existing semantics, and the stale state issue is a narrow transient inconsistency. The recommendations above are suggested as follow-up improvements.


Consolidated review by opus-4.6-thinking. Individual reviews below.


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

Summary

This PR adds dynamic/stateful behavior to st.tabs via key and on_change, including backend widget registration, frontend state sync, and new Python/frontend/e2e tests. The feature direction is strong, but there are two correctness issues in edge cases that should be addressed before merge.

Code Quality

Overall structure is clean and aligned with existing patterns, but I found two blocking logic issues:

  1. Duplicate tab labels are ambiguous in dynamic mode

    • frontend/lib/src/components/elements/Tabs/Tabs.tsx:162-164 stores the selected tab by label (allTabLabels[activeKey]).
    • lib/streamlit/elements/layouts.py:828 restores selection via tabs.index(current_tab_label), which always resolves to the first matching label.
    • Result: with duplicate labels and on_change="rerun", selecting a later duplicate cannot be reliably restored after rerun, and .open can point to the wrong tab.
  2. Fallback from stale session label does not normalize session state

    • lib/streamlit/elements/layouts.py:810-813 falls back current_tab_label when stored state is no longer in tabs, but only in local runtime logic.
    • st.session_state[key] can remain stale and disagree with the actually selected/default tab in the same run.
    • This violates the expectation documented in the PR that the active tab label is available via session state.

Test Coverage

Coverage is good for core flows:

  • Python unit tests cover invalid on_change, .open behavior, and proto widget ID.
  • Frontend unit tests cover widget updates on click and backend-driven selection sync.
  • E2E tests cover lazy execution, programmatic control, and non-rerun behavior for key-only tabs.

Coverage gaps for the issues above:

  • No test for duplicate labels with on_change="rerun" (selection ambiguity).
  • No test asserting st.session_state[key] is corrected when stale stored label falls back to default.

Backwards Compatibility

Default behavior (on_change=None) appears preserved. However, for new dynamic mode, duplicate-label tabs and stale session-state label scenarios can produce incorrect tab selection/state reporting. These are regressions in valid user scenarios and should be fixed before release.

Security & Risk

No direct security concerns identified. Main risk is behavioral regression: app logic that relies on .open or st.session_state[key] can branch incorrectly in edge cases.

Accessibility

No clear accessibility regressions in this PR. It continues using existing BaseWeb tab semantics and does not introduce new non-semantic interactive patterns.

Recommendations

  1. Persist dynamic tab selection by an unambiguous value (e.g., index or stable internal ID), or reject duplicate labels when on_change="rerun" with a clear API error.
  2. When restoring from a stale stored label, also update widget/session state to the fallback label so backend state matches rendered selection.
  3. Add targeted tests for:
    • duplicate labels + on_change="rerun" restore behavior,
    • stale stored label fallback with st.session_state[key] consistency assertion.

Verdict

CHANGES REQUESTED: Dynamic tabs are close, but duplicate-label selection and stale-session-state fallback handling can produce incorrect behavior and should be fixed before merge.


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 key and on_change parameters to st.tabs(), enabling stateful, lazily-executed tabs ("dynamic tabs"). When on_change="rerun", switching tabs triggers a script rerun, and each tab's .open property indicates whether it is the currently active tab (True/False), allowing apps to skip expensive work in hidden tabs. The feature also supports programmatic tab switching via st.session_state.

Changes span the full stack:

  • Protobuf: Added optional string widget_id to TabContainer message.
  • Backend: New key/on_change parameters on st.tabs(), widget registration via register_widget, _TabsSerde dataclass, and .open state management on TabContainer.
  • Frontend: Tabs.tsx reads the new widgetId from proto, calls widgetMgr.setStringValue on tab switch for dynamic tabs, and syncs programmatic defaultTabIndex changes. Block.tsx passes fragmentId to the Tabs component.
  • Tests: 8 new Python unit tests, 3 new frontend unit tests, and 3 new E2E tests.

Code Quality

Backend (lib/streamlit/elements/layouts.py)

The implementation is clean and follows existing Streamlit patterns well. The _TabsSerde dataclass correctly handles serialization/deserialization. The is_stateful flag cleanly separates the dynamic and non-dynamic paths. Good use of check_widget_policies and compute_and_register_element_id. The fallback when a stale label is not in the current tab list is well-handled.

Minor observations:

  1. key is silently ignored when on_change is not "rerun" (lines 770-772). When a user passes key="my_tabs" with on_change=None or on_change="ignore", the key has no effect — it's not registered as a widget, not stored in session state, and (as noted in the PR description) CSS class support is deferred. This behavior is tested in test_on_change_ignore_with_key_open_remains_none and the E2E test_tabs_key_only_does_not_trigger_rerun, which is good. However, users might be surprised that providing a key doesn't do anything. Consider either: (a) documenting this in the docstring, or (b) raising a warning when key is provided without on_change="rerun".

  2. Duplicate tab labels with on_change="rerun": The widget state stores the active tab label as a string. If there are duplicate labels (which are explicitly allowed by st.tabs), the label-based lookup (tabs.index(current_tab_label)) will always resolve to the first occurrence. This is consistent with how default works, but it's an inherent limitation worth noting. Switching to a duplicate tab at a later index will snap back to the first occurrence on rerun.

Frontend (Tabs.tsx)

The frontend changes are well-structured. The isDynamic flag based on widgetId presence cleanly controls behavior. The widgetMgr.setStringValue call in the onChange handler is properly guarded.

  1. useEffect for syncing programmatic tab changes (lines 95-109): This effect syncs tab selection when defaultTabIndex changes from the backend. While this is syncing with an "external system" (the backend proto), it uses a ref (prevDefaultTabIndexRef) to track the previous value and only fires on change. This pattern is acceptable per the React docs, though an alternative approach would be to use the key prop on the component to force a remount when the widget identity changes. The current approach is fine for this use case.

  2. Missing wait_for_app_run in E2E test_tabs_key_only_does_not_trigger_rerun: After clicking tabs, there's no wait_for_app_run call, which is intentional — the test asserts that no rerun happens. However, the test should ideally wait a short moment to confirm the rerun truly doesn't fire. The expect(rerun_text).to_have_text(initial_count or "") auto-waits, which is fine for asserting the text hasn't changed. This looks correct.

Protobuf (Block.proto)

The optional string widget_id field added to TabContainer is well-designed. Using optional allows the frontend to distinguish between "no widget" and "widget with empty ID", which is the right semantic. Good comments explaining when the field is set.

Test Coverage

Python Unit Tests (8 new tests): Excellent coverage of the new functionality:

  • Invalid on_change values
  • .open state with on_change="rerun" (default and custom default)
  • Widget ID presence/absence in proto
  • Session state accessibility
  • Proto tab index correctness
  • Stale session state fallback
  • on_change="ignore" with key

All tests have docstrings as required by the test guide.

Frontend Unit Tests (3 new tests): Good coverage:

  • setStringValue called on dynamic tab click
  • setStringValue NOT called on non-dynamic tab click (negative test)
  • Programmatic sync when defaultTabIndex changes

E2E Tests (3 new tests): Well-designed end-to-end coverage:

  • Lazy execution verification (counts per tab)
  • Programmatic control via session state buttons
  • Key-only tabs don't trigger reruns (with both positive and negative assertions)

The E2E tests follow best practices: using wait_for_app_run, stable locators (get_by_role, get_by_text), expect for auto-wait assertions, and including "must NOT happen" checks.

Potential gaps:

  • No test for st.tabs with on_change="rerun" inside an st.fragment (recommended by the E2E AGENTS.md for widgets).
  • No test for duplicate tab labels with on_change="rerun" to verify the first-occurrence fallback behavior.
  • No typing tests in lib/tests/streamlit/typing/ for the new key and on_change parameters — these are recommended by the project's test strategy for public API changes.

Backwards Compatibility

The changes are fully backwards compatible:

  • key and on_change are optional keyword-only parameters with None defaults.
  • When neither is provided, behavior is identical to the existing implementation: all tabs execute, .open returns None, no widget registration.
  • The protobuf field widget_id is optional, so old frontends won't break with new backends (field will simply be absent), and old backends won't send the field.
  • The tabs variable name shadowing the parameter is handled correctly (the Sequence[str] parameter tabs is consumed before the loop variable tab_label is used).

Security & Risk

No security concerns identified:

  • The widget state only stores tab labels (strings already provided by the developer).
  • No user-supplied data flows into unsafe operations.
  • The register_widget and check_widget_policies functions handle standard widget security.

Regression risk: Low. The changes are well-isolated behind the is_stateful / isDynamic flags, meaning the non-dynamic code path is minimally modified. The main change to the existing path is the refactoring from a list comprehension to a for loop (lines 840-849), which is functionally equivalent.

Accessibility

No new accessibility concerns. The tabs component continues to use BaseWeb's UITabs which provides proper ARIA roles (tablist, tab, tabpanel), keyboard navigation (activateOnFocus), and aria-selected attributes. The dynamic behavior only affects when content is rendered, not how it's presented to assistive technology.

Recommendations

  1. Document key behavior without on_change="rerun": The key docstring says "An optional string or integer to use as the unique key for the widget" but when on_change is not "rerun", it doesn't register as a widget at all. Consider adding a note like: "When on_change is not 'rerun', the key parameter has no effect (CSS class and session state support are planned for a future release)."

  2. Add typing tests: Consider adding a typing test file at lib/tests/streamlit/typing/tabs_types.py to verify the public API types for the new key and on_change parameters, following the pattern of existing typing tests.

  3. Consider duplicate label behavior: When on_change="rerun" and there are duplicate tab labels, the stored label will always resolve to the first occurrence on rerun. While this is consistent with the default parameter behavior, it could surprise users. A one-line note in the docstring would help.

  4. Minor: E2E test for fragment context: The E2E AGENTS.md recommends testing widgets inside @st.fragment. Consider adding a follow-up test for dynamic tabs inside a fragment to ensure fragmentId propagation works correctly end-to-end.

  5. Consider the on_change naming convention: Most Streamlit widgets accept a callable for on_change. Here it's a string literal ("rerun" / "ignore"). This is consistent with st.dialog's on_dismiss parameter but differs from most widgets. The docstring explains this well, but the different semantics could confuse users who expect a callback. This seems like a deliberate design decision (matching the pattern from the stacked PR [DynamicContainers] foundational work adding .open properties #13867) — just flagging it for awareness.

Verdict

APPROVED: This is a well-implemented feature addition with clean code, good test coverage, full backwards compatibility, and no security concerns. The minor recommendations above are non-blocking improvements that could be addressed in follow-up PRs.


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

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.

Adding a case with fragment here as suggested by AI might make sense.

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, thanks!

@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-11-_dynamiccontainers_dynamic_tabs branch from ed2aa5a to f0ce12b Compare February 16, 2026 14:23
@sfc-gh-lwilby
Copy link
Copy Markdown
Collaborator Author

@lukasmasuch also updated the other two widget PRs with the same changes.

// Update widget state for dynamic tabs
if (isDynamic && widgetId && widgetMgr) {
widgetMgr.setStringValue(
{ id: widgetId, formId: "" },
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

question: Can't we pass in the node.deltaBlock?.tabContainer here as well similar to how we do it for most widgets. But not sure if thats a lot better.

And, I guess the usage in form is not supported. Is there some check that makes sure that lazy tabs aren't used in forms?

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.

We could use node.deltaBlock?.tabContainer, but it is not very different from this.

Good point about using these inside of a form. Currently there is no check, but we should handle this explicitly in some way. I will make a follow up PR. Disallowing lazy tabs inside of forms seems like the right call to me.


ctx = get_script_run_ctx()

element_id = compute_and_register_element_id(
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.

Same comment as on expander, potentially also compute ID when key is set.

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 👍

@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-11-_dynamiccontainers_dynamic_tabs branch from f0ce12b to d23672d Compare February 18, 2026 14:38
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-11-_dynamiccontainers_dynamic_tabs branch from d23672d to 7bbc231 Compare February 18, 2026 14:54
@sfc-gh-lwilby sfc-gh-lwilby merged commit 651a7d7 into develop Feb 18, 2026
44 checks passed
@sfc-gh-lwilby sfc-gh-lwilby deleted the 02-11-_dynamiccontainers_dynamic_tabs branch February 18, 2026 15:58
lukasmasuch pushed a commit that referenced this pull request Feb 20, 2026
## Describe your changes

Test Demo App: https://dynamic-tabs.streamlit.app/

**Stacked on #13867.**

Adds `key` and `on_change` parameters to `st.tabs`, enabling stateful,
lazily-executed tabs. When `on_change="rerun"`, switching tabs triggers
a rerun and each tab's `.open` property returns `True` for the active
tab and `False` for inactive ones. This lets apps skip expensive work in
hidden tabs:

```python
tabs = st.tabs(["Sales", "Customers"], on_change="rerun")

if tabs[0].open:
    with tabs[0]:
        expensive_sales_query()

if tabs[1].open:
    with tabs[1]:
        expensive_customer_query()
```

Programmatic tab switching is supported via `st.session_state`:

```python
def goto_settings():
    st.session_state.my_tabs = "Settings"

st.button("Go to Settings", on_click=goto_settings)
tabs = st.tabs(["Home", "Settings"], key="my_tabs", on_change="rerun")
```

### Backend

- Adds `key` and `on_change` parameters to `st.tabs()`.
- When `on_change="rerun"`, registers tabs as a widget via
`register_widget`, tracking the active tab label as a string value. Sets
`.open = True` on the active `TabContainer` and `.open = False` on the
rest. Without `on_change`, `.open` remains `None`.
- Falls back to the default tab when the stored label is no longer in
the tab list.
- **Note:** `st-key-` CSS class support for tabs is deferred to a
follow-up PR to ensure the class is applied to the correct DOM element.

### Frontend

- On tab switch in dynamic tabs, sends the new tab label to the backend
via `widgetMgr.setStringValue`.
- Syncs the selected tab when `defaultTabIndex` changes from the backend
(programmatic control).
- Passes `fragmentId` to the `Tabs` component for correct
fragment-scoped widget updates.

## GitHub Issue Link (if applicable)

#6004

## Testing Plan

- [x] Unit Tests (JS and/or Python)
- [x] E2E Tests
- [x] See demo app pointed to this PR for manual testing:
https://dynamic-tabs.streamlit.app/

**Python unit tests** (`lib/tests/streamlit/elements/layouts_test.py`):
8 tests covering invalid `on_change` values, `.open` state with and
without `default`, session state accessibility, and correct proto tab
index.

**Frontend unit tests**
(`frontend/lib/src/components/elements/Tabs/Tabs.test.tsx`): Tests for
dynamic tab behavior including widget state updates on tab switch and
programmatic sync from backend.

**E2E tests** (`e2e_playwright/st_tabs_test.py`):
`test_dynamic_tabs_lazy_execution` verifies only the active tab's
content executes on each rerun; `test_dynamic_tabs_programmatic_control`
verifies switching tabs via session state buttons.

---

**Contribution License Agreement**

By submitting this pull request you agree that all contributions to this
project are made under the Apache 2.0 license.

Co-authored-by: lawilby <[email protected]>
sfc-gh-lwilby added a commit that referenced this pull request Feb 27, 2026
…#14143)

## Describe your changes

The partial change that landed with callable `on_change` support used
`callable(on_change)` inline in the `check_widget_policies` call for all
three dynamic containers, but without the `is_callback` intermediate
variable or `cast`. This aligns all three with the established pattern
from `vega_charts.py`:

- Extracts `is_callback = callable(on_change)` as a named variable for
clarity
- Passes `on_change=cast("WidgetCallback", on_change) if is_callback
else None` for type correctness
- `enable_check_callback_rules=is_callback` (functionally equivalent,
but consistent with the pattern)

## Screenshot or video (only for visual changes)

N/A

## GitHub Issue Link (if applicable)

Surfaced in review of #13910.

## Testing Plan

- [x] Python unit tests — added 6 regression tests to
`lib/tests/streamlit/elements/layouts_test.py`, covering all three
containers:
- `test_callable_on_change_inside_form_raises` — verifies a callable
`on_change` inside `st.form` raises `StreamlitInvalidFormCallbackError`
(expander, tabs, popover)
- `test_on_change_rerun_inside_form_does_not_raise` — verifies
`on_change="rerun"` inside `st.form` is still allowed (expander, tabs,
popover)

---

**Contribution License Agreement**

By submitting this pull request you agree that all contributions to this
project are made under the Apache 2.0 license.

---------

Co-authored-by: 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