Skip to content

[feature] Stable Block.id for passive keyed layout containers#14330

Merged
sfc-gh-lwilby merged 10 commits intodevelopfrom
feature/passive-keyed-block-identity
Mar 19, 2026
Merged

[feature] Stable Block.id for passive keyed layout containers#14330
sfc-gh-lwilby merged 10 commits intodevelopfrom
feature/passive-keyed-block-identity

Conversation

@sfc-gh-lwilby
Copy link
Copy Markdown
Collaborator

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

Describe your changes

Sets a stable Block.id via compute_and_register_element_id (with key_as_main_identity=True) for st.tabs, st.expander, and st.popover when key is provided and on_change="ignore" (the default). This gives these containers a position-independent identity — the foundation for frontend state persistence across remounts.

  • Only Block.id is set, not the element-level ID (tabContainer.id / expandable.id / popover.id), so session_state[key] is not populated and the element is not registered as a widget
  • Frontend ElementsSetVisitor now collects Block.id values and includes them in the active ID set passed to removeInactive, preventing elementStates entries from being garbage-collected

Follow-up PRs will wire up useWidgetManagerElementState in each component to persist active tab / expanded / open state across remounts, and apply st-key-* CSS classes.

GitHub Issue Link (if applicable)

Addresses #8239

Testing Plan

  • Unit Tests (JS and/or Python)
    • Python: block_proto.id set for passive keyed containers, absent without key, element-level ID not set
    • Frontend: ElementsSetVisitor collects block IDs from nested trees, ignores blocks without IDs

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 Mar 11, 2026

@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-14330/streamlit-1.55.0-py3-none-any.whl
📦 @streamlit/component-v2-lib Download from artifacts
🕹️ Preview app pr-14330.streamlit.app (☁️ Deploy here if not accessible)

@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 changed the title feat: set stable Block.id for passive keyed layout containers [feature] Stable Block.id for passive keyed layout containers Mar 11, 2026
sfc-gh-lwilby and others added 4 commits March 11, 2026 15:54
When `key` is provided and `on_change="ignore"` (the default) for
st.tabs, st.expander, and st.popover, compute and set `block_proto.id`
via `compute_and_register_element_id` with `key_as_main_identity=True`.

This gives these containers a stable identity independent of their
position in the render tree, which is the foundation for frontend
state persistence across remounts (coming in follow-up PRs).

Only `Block.id` is set — NOT the element-level ID (e.g.
`tabContainer.id`), so these elements are not registered as widgets
and `session_state[key]` is not populated.

On the frontend, extend `ElementsSetVisitor` to collect `Block.id`
values and include them in the active ID set passed to
`removeInactive`, preventing elementStates entries from being
garbage-collected prematurely.

Addresses #8239

Made-with: Cursor

Co-authored-by: lawilby <[email protected]>
…ock.id

Add tests verifying that block.id remains stable across re-renders when
parameters change (label, expanded, tab labels, type) but the key stays
the same. Also add tests for tabs and popover confirming block.id is
empty when on_change="rerun" or a callback is used (expander already
had these).

Made-with: Cursor

Co-authored-by: lawilby <[email protected]>
Restore the TODO comments in App.tsx setState callbacks that were
inadvertently dropped. Clarify that this.state usage in the callback
is a false positive (callback runs post-commit) and that converting
App to a functional component is the proper fix.

Also add stability and mutual-exclusivity tests for passive keyed
block.id across expander, tabs, and popover.

Made-with: Cursor

Co-authored-by: lawilby <[email protected]>
Move the blockWithId helper from ElementsSetVisitor.test.ts to
the shared render-tree/test-utils.ts alongside the existing block
helper. Simplifies the test file imports.

Made-with: Cursor

Co-authored-by: lawilby <[email protected]>
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the feature/passive-keyed-block-identity branch from 00b4b2e to 4f40ea6 Compare March 11, 2026 14:58
Change the stateful path (on_change="rerun" / callback) for st.tabs,
st.expander, and st.popover to use key_as_main_identity=True, matching
the convention used by all other Streamlit widgets.

Previously these used key_as_main_identity=False, which caused the
widget ID to change (and session_state to reset) whenever cosmetic
parameters like label, expanded, or tab labels changed. With True, the
element ID is key-based and stable across parameter changes.

Also update the tech spec to accurately describe the passive vs
stateful paths and note key_as_main_identity=True for both.

Made-with: Cursor

Co-authored-by: lawilby <[email protected]>
only the element-level ID (e.g. `tabContainer.id`) is set — the container is registered as
a widget and backend widget state becomes the source of truth.

Both paths use `key_as_main_identity=True` so the ID is key-based and stable across
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.

This needed to be specified for the agent implementation.

…s provided

block_proto.id drives the CSS st-key-* class and must be set whenever
a key is provided, regardless of on_change mode. Restructure the
if/elif blocks so register_widget stays in the stateful path and
block_id is computed in both paths. Restore kwargs in the stateful
compute_and_register_element_id call for uniqueness when no key is
provided.

Made-with: Cursor

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

These things needed to be clarified for the agent in the tech spec.

@sfc-gh-lwilby sfc-gh-lwilby marked this pull request as ready for review March 11, 2026 15:28
Copilot AI review requested due to automatic review settings March 11, 2026 15:28
@sfc-gh-lwilby sfc-gh-lwilby added change:feature PR contains new feature or enhancement implementation impact:users PR changes affect end users labels Mar 11, 2026
@sfc-gh-lwilby sfc-gh-lwilby added the ai-review If applied to PR or issue will run AI review workflow label Mar 11, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces stable, key-based Block.id values for passive keyed layout containers (st.tabs, st.expander, st.popover) to enable frontend state persistence across remounts without registering these containers as widgets. On the frontend, active Block.id values are added to the “active IDs” set so elementStates keyed by Block.id are not garbage-collected.

Changes:

  • Backend: Compute and set Block.id for keyed passive containers (and align stateful container IDs to be key-based when key is provided).
  • Frontend: Extend ElementsSetVisitor to collect Block.id values and pass them to WidgetStateManager.removeInactive.
  • Tests/spec: Add Python and frontend unit tests plus update the tech spec to reflect the new ID semantics.

Reviewed changes

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

Show a summary per file
File Description
specs/2026-02-26-layout-container-state-persistence/tech-spec.md Updates the design notes to clarify Block.id behavior for passive vs stateful containers.
lib/streamlit/elements/layouts.py Computes/stores stable Block.id for keyed tabs/expanders/popovers and makes stateful IDs key-based when a key is provided.
lib/tests/streamlit/elements/layouts_test.py Adds regression tests for Block.id being set/stable for keyed passive containers and set for keyed stateful paths.
frontend/lib/src/render-tree/visitors/ElementsSetVisitor.ts Collects deltaBlock.id values while traversing the render tree.
frontend/lib/src/render-tree/visitors/ElementsSetVisitor.test.ts Adds tests validating Block.id collection behavior (including nested trees).
frontend/lib/src/render-tree/test-utils.ts Adds blockWithId helper for visitor tests.
frontend/lib/src/render-tree/AppRoot.ts Adds getActiveIds() to expose both active elements and collected block IDs.
frontend/app/src/App.tsx Uses getActiveIds() and includes block IDs in removeInactive to preserve elementStates for keyed containers.

tab_container_block = all_deltas[0]
assert tab_container_block.add_block.id != ""
assert "my_tabs" in tab_container_block.add_block.id
assert tab_container_block.add_block.tab_container.id == ""
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

For consistency with the other tab-container ID assertions in this file (and to ensure we’re checking field presence, not just the default string value), please assert that tab_container.id is not set via HasField("id") instead of comparing the string to "".

Suggested change
assert tab_container_block.add_block.tab_container.id == ""
assert not tab_container_block.add_block.tab_container.HasField("id")

Copilot uses AI. Check for mistakes.
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Mar 11, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR introduces a stable Block.id for keyed layout containers (st.tabs, st.expander, and st.popover) using compute_and_register_element_id with key_as_main_identity=True. This gives these containers a position-independent identity, which is a foundational step for frontend state persistence across remounts. Key changes span three layers:

  • Backend (Python): When key is provided, block_proto.id is set to a key-based stable ID in both the stateful path (with widget registration) and a new passive path (block ID only, no widget registration). For st.tabs, key_as_main_identity in the stateful path was changed from False to True, aligning it with st.expander and st.popover.
  • Frontend (TypeScript): ElementsSetVisitor now collects Block.id values alongside elements, and AppRoot.getActiveIds() returns both element and block IDs. App.tsx passes the combined set to removeInactive to prevent elementStates entries from being garbage-collected.
  • Tests: Comprehensive Python unit tests (17+ new/updated) and 4 new frontend unit tests for block ID collection.

Code Quality

All three reviewers agreed the code is clean, well-structured, and follows existing Streamlit patterns consistently across all three container types.

Notable observations (raised by opus-4.6-thinking):

  1. Duplicated code in App.tsx: The same 7-line block for building activeWidgetIds and calling removeInactive appears in two setState callbacks. A small helper method could reduce this duplication. (Minor)
  2. Variable naming: activeWidgetIds now contains both widget IDs and block IDs — a name like activeIds would be more accurate. (Cosmetic)
  3. Outdated JSDoc on ElementsSetVisitor: The class-level JSDoc describes the visitor as collecting "all Elements" but doesn't mention the new blockIds collection. (Minor)
  4. Missing AppRoot.getActiveIds() unit test: While ElementsSetVisitor.blockIds is well-tested, AppRoot.getActiveIds() has no direct test in AppRoot.test.ts. Since it's a thin wrapper, this is low risk. (Minor)

gemini-3.1-pro additionally noted the eslint-disable-next-line in App.tsx is well-justified with a comment explaining the false positive and a future path for removal.

Test Coverage

All three reviewers agreed test coverage is strong:

  • Python unit tests (layouts_test.py): Comprehensive tests verifying block ID is set for passive keyed containers, remains stable when parameters change, is not set when no key is provided, and works in both stateful and passive paths.
  • Frontend unit tests (ElementsSetVisitor.test.ts): Tests for block ID collection from blocks with/without IDs, nested blocks, and coexistence with element collection.
  • No E2E tests: All reviewers agreed this is acceptable since the PR is foundational plumbing with no user-visible effect yet — follow-up PRs will add E2E coverage.

Suggested additions (non-blocking):

  • Integration-level frontend test around WidgetStateManager.removeInactive + active block IDs (gpt-5.3-codex-high)
  • AppRoot.getActiveIds() unit test (opus-4.6-thinking)

Backwards Compatibility

Point of nuance across reviewers:

  • gemini-3.1-pro stated "fully backwards compatible" without caveats.
  • gpt-5.3-codex-high noted keyed stateful IDs now use key-main identity, so internal IDs may differ — a "one-time state reset" is possible during transition, but long-term behavior is more stable.
  • opus-4.6-thinking provided the most detailed analysis, specifically flagging the key_as_main_identity change for st.tabs (from False to True in the stateful path). If stateful st.tabs with on_change has already shipped, this would be a breaking change as existing keyed tab widget state would reset. If still in development, this is a non-issue and aligns tabs with expander/popover behavior.

Consolidated assessment: The key_as_main_identity change for st.tabs is the only backward-compatibility concern. The PR author should confirm that stateful st.tabs (with on_change) has not yet shipped in a release. If it has shipped, the ID migration impact should be documented. Setting block_proto.id itself is additive and safe (optional proto field, only consumed in the new visitor path). The passive path does not register widgets or populate session_state[key], preserving existing behavior.

Security & Risk

All three reviewers unanimously agreed: No security concerns. No new endpoints, routes, auth/cookie/CSRF changes, external dependencies, or code execution risks. The changes are purely internal state management. Risk is low and localized to layout container identity/state cleanup.

External test recommendation

  • Recommend external_test: No (unanimous across all reviewers)
  • Triggered categories: None
  • Evidence:
    • lib/streamlit/elements/layouts.py: Internal state management (setting block_proto.id), no routing/auth/embedding/cross-origin impact.
    • frontend/app/src/App.tsx: Internal GC logic changes to removeInactive active set.
    • frontend/lib/src/render-tree/visitors/ElementsSetVisitor.ts: Visitor pattern extension, no external behavior change.
  • Confidence: High
  • Assumptions: Follow-up PRs that consume elementStates with block IDs should be separately assessed for external test needs.

Accessibility

All three reviewers unanimously agreed: No accessibility impact. Changes are purely backend/state-management plumbing with no DOM, ARIA, focus, or keyboard interaction changes.

Recommendations

  1. [Important] Clarify backward compatibility of key_as_main_identity change for st.tabs: Confirm stateful st.tabs with on_change hasn't shipped yet, or document the one-time state reset impact. (Raised by opus-4.6-thinking and gpt-5.3-codex-high)

  2. [Minor] Extract duplicated removeInactive pattern in App.tsx: The identical block for building activeWidgetIds and calling removeInactive appears twice. A small helper method would reduce duplication. (Raised by opus-4.6-thinking)

  3. [Minor] Update ElementsSetVisitor class-level JSDoc to mention blockIds collection. (Raised by opus-4.6-thinking)

  4. [Minor] Consider renaming activeWidgetIds to activeIds to reflect its expanded scope. (Raised by opus-4.6-thinking)

  5. [Minor] Add AppRoot.getActiveIds() unit test for completeness. (Raised by opus-4.6-thinking)

  6. [Minor] Add integration-level frontend test for WidgetStateManager.removeInactive + active block IDs. (Raised by gpt-5.3-codex-high)

  7. [Minor] Consider a release-note mention that keyed stateful container identity has been stabilized. (Raised by gpt-5.3-codex-high)

Verdict

APPROVED: All three reviewers approved unanimously. The PR is well-designed foundational plumbing with consistent patterns across all three container types, strong unit test coverage, and no security or accessibility risks. The recommendations above are non-blocking — the only item worth confirming before merge is the backward compatibility of the key_as_main_identity change for stateful st.tabs.


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


📋 Review by `gemini-3.1-pro`

Summary

This PR introduces a stable Block.id for passive keyed layout containers (st.tabs, st.expander, and st.popover) when a key is provided and on_change="ignore". This provides these containers with a position-independent identity, which is a foundational step for frontend state persistence across remounts. It also updates the frontend ElementsSetVisitor to collect these block IDs and pass them to the widget manager to prevent their state from being garbage-collected.

Code Quality

The code changes are clean, well-structured, and follow the project's best practices.

  • In lib/streamlit/elements/layouts.py, the logic for computing and setting block_id is correctly applied to both stateful and passive paths when a key is present.
  • In the frontend, the ElementsSetVisitor is appropriately updated to collect blockIds alongside elements, and App.tsx correctly merges these sets to keep the widget manager informed of active IDs.
  • The use of eslint-disable-next-line in App.tsx is well-justified with a comment explaining the false positive and a future path for removal.

Test Coverage

Test coverage is excellent.

  • Python Unit Tests: lib/tests/streamlit/elements/layouts_test.py includes comprehensive tests verifying that block.id is set for passive keyed containers, remains stable when parameters change, and is not set when no key is provided.
  • Frontend Unit Tests: frontend/lib/src/render-tree/visitors/ElementsSetVisitor.test.ts thoroughly tests the new blockIds collection logic, including nested blocks and blocks without IDs.

Backwards Compatibility

These changes are fully backwards compatible. The addition of Block.id for passive containers does not alter existing public APIs or behavior, but rather enhances the internal representation to support future state persistence features.

Security & Risk

No security concerns or regression risks identified. The changes only affect internal ID generation and frontend state garbage collection logic for layout containers.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • frontend/app/src/App.tsx, frontend/lib/src/render-tree/AppRoot.ts, frontend/lib/src/render-tree/visitors/ElementsSetVisitor.ts: Updates to frontend garbage collection logic for active widget/block IDs.
    • lib/streamlit/elements/layouts.py: Sets block_id on protobufs for keyed layout containers.
  • Suggested external_test focus areas: N/A
  • Confidence: High
  • Assumptions and gaps: The changes are purely related to internal state management and protobuf ID assignment, with no impact on routing, auth, websockets, or embedding boundaries.

Accessibility

No accessibility impact. The changes are related to internal state management and do not alter the DOM structure or ARIA attributes.

Recommendations

None. The implementation is solid and ready for the follow-up PRs mentioned in the description.

Verdict

APPROVED: The changes are well-implemented, thoroughly tested, and safely introduce the necessary foundation for layout container state persistence.


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 establishes stable Block.id identity for keyed st.tabs, st.expander, and st.popover, and updates frontend active-ID collection so block-level IDs are preserved during inactivity cleanup. It also adds focused backend and frontend unit tests for ID generation and block-id traversal behavior.

Code Quality

The implementation is clean and consistent with existing Streamlit patterns:

  • Backend changes in lib/streamlit/elements/layouts.py correctly separate widget-level IDs (stateful mode) from block-level IDs (passive mode), while making keyed identity stable via key_as_main_identity=True.
  • Frontend render-tree updates in frontend/lib/src/render-tree/visitors/ElementsSetVisitor.ts and frontend/lib/src/render-tree/AppRoot.ts are minimal and cohesive.
  • frontend/app/src/App.tsx now includes block IDs in the removeInactive active ID set at both cleanup callsites, which is the right integration point for elementStates retention.

No blocking code-quality issues were found.

Test Coverage

Coverage is strong at unit level for the scope of this PR:

  • Python unit tests in lib/tests/streamlit/elements/layouts_test.py validate block-id behavior for passive and stateful keyed cases, plus stability when parameters change.
  • Frontend unit tests in frontend/lib/src/render-tree/visitors/ElementsSetVisitor.test.ts validate block-id collection (including nested blocks) and coexistence with element collection.

No e2e tests were added in this PR. Given this PR is foundational plumbing (state persistence wiring in components is explicitly follow-up work), this is acceptable for now.

Backwards Compatibility

No API-breaking changes were identified. Existing apps continue to function with the same public API.

Behavioral note: keyed stateful IDs for these containers now use key-main identity, so internal IDs may differ from prior versions. A one-time state reset for those specific internal IDs is possible during transition, but long-term behavior is more stable and consistent with keyed widget semantics.

Security & Risk

No security-sensitive surfaces were modified (no auth/session transport changes, no route handling changes, no upload/download handlers, no CSP/CORS/cookie/XSRF changes, no dynamic code execution paths). Regression risk appears low and localized to layout container identity/state cleanup behavior.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • lib/streamlit/elements/layouts.py: ID generation changes are internal to element identity for layout containers; no routing/auth/cookie/websocket/cross-origin logic touched.
    • frontend/lib/src/render-tree/visitors/ElementsSetVisitor.ts: active ID collection update for render-tree traversal only.
    • frontend/app/src/App.tsx: passes expanded active ID set to removeInactive; no networking or browser policy changes.
  • Suggested external_test focus areas:
    • None required for this PR specifically.
    • Follow-up PRs that wire persisted state into UI behavior should consider embedded/proxy runs if cross-origin or iframe behavior is touched.
  • Confidence: High
  • Assumptions and gaps: Assessment is based on this PR's changed files only; no hidden runtime flags or unrelated infra changes were observed in diff scope.

Accessibility

No frontend accessibility regressions identified in this diff. Changes are render-tree/state-management internals and do not alter interactive markup, ARIA attributes, focus behavior, or keyboard interaction flows.

Recommendations

  1. Add an integration-level frontend test around WidgetStateManager.removeInactive + active block IDs to guard against future regressions in block-id retention/cleanup plumbing.
  2. Consider a brief release-note mention that keyed stateful container identity has been stabilized, to set expectations around possible one-time internal state transition behavior.

Verdict

APPROVED: The changes are well-scoped, well-tested at unit level, and ready to merge with low regression/security risk.


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 sets a stable Block.id on st.tabs, st.expander, and st.popover when a key is provided, using compute_and_register_element_id with key_as_main_identity=True. This gives these containers a position-independent identity that is the foundation for frontend state persistence across remounts.

Key changes:

  • Backend (Python): For all three layout containers, when key is provided, block_proto.id is set to a key-based stable ID — both in the stateful path (where the element-level ID is also set for widget registration) and in a new passive path (where only block_proto.id is set, with no widget registration).
  • Frontend (TypeScript): ElementsSetVisitor now collects Block.id values from blocks, and AppRoot.getActiveIds() returns both element and block IDs. In App.tsx, the removeInactive calls now include block IDs in the active set to prevent elementStates entries from being garbage-collected.
  • For st.tabs specifically, key_as_main_identity in the stateful path was changed from False to True, aligning it with st.expander and st.popover.

Code Quality

The code is well-structured and follows existing patterns consistently across all three container types (tabs, expander, popover). The same block_id pattern is applied identically to each.

Minor observations:

  1. Duplicated code in App.tsx (lines 1601–1608 and 1667–1674): The same block of code for building activeWidgetIds from getActiveIds() and calling removeInactive appears in two setState callbacks. A small helper method could reduce duplication, though this is a minor style concern.

  2. getActiveIds() JSDoc in AppRoot.ts (lines 391–396): The class-level JSDoc on ElementsSetVisitor (line 26–38) describes the visitor as collecting "all Elements from an AppNode tree" but doesn't mention blockIds collection. Consider updating the class-level JSDoc to reflect the expanded responsibility.

  3. Variable naming in App.tsx: The variable activeWidgetIds now contains both widget IDs and block IDs. A more precise name like activeIds would better reflect its contents, though this is a cosmetic concern.

  4. No AppRoot.getActiveIds() unit test: While ElementsSetVisitor.blockIds is well-tested, AppRoot.getActiveIds() itself has no direct unit test in AppRoot.test.ts (unlike getElements() which has a dedicated describe block). Since it's a thin wrapper around the visitor, this is low risk, but a test would add confidence.

Test Coverage

Python tests (layouts_test.py): Excellent coverage with 17+ new/updated tests across the three containers:

  • Passive key sets block-level ID correctly
  • Passive key does not set element-level ID (no widget registration)
  • Block ID is stable when parameters change (using _register_element_id mock)
  • Stateful path with key sets block-level ID
  • Without key, block-level ID is not set
  • Callback with key sets block-level ID

Frontend tests (ElementsSetVisitor.test.ts): Good coverage with 4 new tests:

  • Collects block IDs from blocks with id set
  • Does not collect IDs from blocks without id
  • Collects IDs from nested blocks
  • Collects elements and block IDs simultaneously

Gaps:

  • No AppRoot.getActiveIds() unit test (minor, as discussed above).
  • No E2E tests, but this is appropriate since the feature has no user-visible effect yet — the PR description notes that follow-up PRs will wire up the frontend components.

Backwards Compatibility

key_as_main_identity change for st.tabs: The stateful path for st.tabs changes key_as_main_identity from False to True (line 888 of layouts.py). When a key is provided, this means the element ID will no longer include tab labels, width, or default in its computation. If the on_change parameter for st.tabs has already shipped in a release, this would be a breaking change — existing users with stateful keyed tabs would see their widget state reset because the element ID changes. However, if this feature is still in development and hasn't shipped yet, this is a non-issue and actually aligns tabs with the existing behavior of st.expander and st.popover (which already used key_as_main_identity=True).

Setting block_proto.id: Previously, block_proto.id was not set in many cases (e.g., passive keyed expander, passive keyed popover, and all stateful containers with a key). Now it is set. Since Block.id is an optional string in the proto, and the frontend code only reads it in the new ElementsSetVisitor path, this is additive and backward compatible.

No impact on session_state: The passive path does not register widgets or populate session_state[key], which preserves the existing behavior.

Security & Risk

No security concerns identified. The changes are purely internal state management:

  • No new endpoints, routes, or network behavior
  • No changes to authentication, cookies, or CSRF handling
  • No new external dependencies or asset loading
  • No HTML/Markdown rendering or sanitization changes
  • No JavaScript execution risks (eval, Function(), etc.)

The compute_and_register_element_id call in the passive path correctly registers the element ID to detect duplicate keys, preventing potential confusion or state corruption.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • lib/streamlit/elements/layouts.py: Internal state management changes (setting block_proto.id) with no routing, auth, embedding, or cross-origin impact.
    • frontend/app/src/App.tsx: Changes to removeInactive active set composition — purely internal garbage collection logic.
    • frontend/lib/src/render-tree/visitors/ElementsSetVisitor.ts: Visitor pattern extension for block ID collection — no external behavior change.
  • Suggested external_test focus areas: None — changes are entirely internal plumbing for layout container identity.
  • Confidence: High
  • Assumptions and gaps: Assumes follow-up PRs that actually consume elementStates with block IDs will be separately assessed for external test needs.

Accessibility

No accessibility impact. This PR does not change any rendered UI, DOM structure, ARIA attributes, or keyboard interaction. The changes are purely backend/state-management plumbing. Accessibility considerations will be relevant in follow-up PRs that implement the frontend state persistence and st-key-* CSS classes.

Recommendations

  1. Clarify backward compatibility of key_as_main_identity change for tabs: If stateful st.tabs with on_change has shipped in any release, the key_as_main_identity=FalseTrue change in the stateful path (line 888) is a breaking change for element IDs. Confirm this feature hasn't shipped yet, or document the ID migration impact.

  2. Consider adding an AppRoot.getActiveIds() unit test: A simple test in AppRoot.test.ts verifying that getActiveIds() returns the expected { elements, blockIds } structure (including a block with an ID) would improve coverage of the new public method.

  3. Update ElementsSetVisitor class-level JSDoc: The class documentation (lines 26–38 of ElementsSetVisitor.ts) should mention that the visitor also collects blockIds from blocks with stable identity, not just elements.

  4. Consider extracting the duplicated removeInactive pattern in App.tsx: The identical 7-line block that builds activeWidgetIds and calls removeInactive appears twice. A small private helper method would reduce duplication and make future changes easier.

Verdict

APPROVED: Well-designed foundational PR with consistent patterns across all three container types, strong unit test coverage, and no security or accessibility risks. The only notable concern is the key_as_main_identity change for stateful tabs, which should be confirmed as non-breaking for shipped versions.


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

@sfc-gh-lwilby sfc-gh-lwilby marked this pull request as draft March 11, 2026 17:56
# and the element-level ID (e.g. expandable_proto.id). The change here is to
# also compute it for the passive case — setting block_proto.id only.
if user_key and not is_stateful:
# Both paths use key_as_main_identity=True so the ID is key-based.
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.

I'm not sure if we really need key_as_main_identity=True for this. It would be great if we could activate this, but it would need to get some testing to see if we can dynamically update the tabs (labels / count) without breaking anything.

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.

The key is always integrated into the ID even with key_as_main_identit=False which I think is enough for this feature. And the ID will also stay stable as long as the parameters don't change.

Copy link
Copy Markdown
Collaborator Author

@sfc-gh-lwilby sfc-gh-lwilby Mar 17, 2026

Choose a reason for hiding this comment

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

Updated to key_as_main_identity=False for this PR.

I was thinking, perhaps if users want to use key_as_main_identity but have it update when the tab labels/expander label changes then they should add those to the key since they are all strings? But this is not the main purpose of the PR so I have removed it so it can be addressed separately.

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.

sounds good, yeah I think we could track this somewhere, e.g. as an issue

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.

Created #14416 to track this.

- Change key_as_main_identity from True to False for all 3 container
  elements (tabs, expander, popover) in both stateful and passive paths.
  key_as_main_identity=True can be revisited in a separate PR.
- Remove stable-when-params-change tests (no longer applicable with False).
- Always set block_id = element_id in the stateful path regardless of
  whether key is provided, per reviewer suggestion.
- Update tech spec to reflect both changes.

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

and calls `register_widget` — widget state becomes the source of truth and the
`elementStates` entry keyed by `Block.id` is no longer read.
passive and stateful modes — it drives the CSS `st-key-*` class and (in passive mode) the
`elementStates` key. Both paths use `key_as_main_identity=True` so the ID is key-based and
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.

I think this needs an update based on the latest version

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.

Updated the spec to reflect key_as_main_identity=False and reference #14416 for tracking the future key_as_main_identity=True work.

The spec previously stated both paths use key_as_main_identity=True,
but the implementation uses False. Updated to match the current code
and reference #14416 for future key_as_main_identity=True work.

Made-with: Cursor

Co-authored-by: lawilby <[email protected]>
@sfc-gh-lwilby sfc-gh-lwilby merged commit cf7a5c9 into develop Mar 19, 2026
44 checks passed
@sfc-gh-lwilby sfc-gh-lwilby deleted the feature/passive-keyed-block-identity branch March 19, 2026 07:02
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