[feature] Stable Block.id for passive keyed layout containers#14330
[feature] Stable Block.id for passive keyed layout containers#14330sfc-gh-lwilby merged 10 commits intodevelopfrom
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
✅ PR preview is ready!
|
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
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]>
00b4b2e to
4f40ea6
Compare
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 |
There was a problem hiding this comment.
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]>
There was a problem hiding this comment.
These things needed to be clarified for the agent in the tech spec.
There was a problem hiding this comment.
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.idfor keyed passive containers (and align stateful container IDs to be key-based whenkeyis provided). - Frontend: Extend
ElementsSetVisitorto collectBlock.idvalues and pass them toWidgetStateManager.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 == "" |
There was a problem hiding this comment.
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 "".
| assert tab_container_block.add_block.tab_container.id == "" | |
| assert not tab_container_block.add_block.tab_container.HasField("id") |
SummaryThis PR introduces a stable
Code QualityAll three reviewers agreed the code is clean, well-structured, and follows existing Streamlit patterns consistently across all three container types. Notable observations (raised by
Test CoverageAll three reviewers agreed test coverage is strong:
Suggested additions (non-blocking):
Backwards CompatibilityPoint of nuance across reviewers:
Consolidated assessment: The Security & RiskAll 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
AccessibilityAll three reviewers unanimously agreed: No accessibility impact. Changes are purely backend/state-management plumbing with no DOM, ARIA, focus, or keyboard interaction changes. Recommendations
VerdictAPPROVED: 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 This is a consolidated AI review by 📋 Review by `gemini-3.1-pro`SummaryThis PR introduces a stable Code QualityThe code changes are clean, well-structured, and follow the project's best practices.
Test CoverageTest coverage is excellent.
Backwards CompatibilityThese changes are fully backwards compatible. The addition of Security & RiskNo 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
AccessibilityNo accessibility impact. The changes are related to internal state management and do not alter the DOM structure or ARIA attributes. RecommendationsNone. The implementation is solid and ready for the follow-up PRs mentioned in the description. VerdictAPPROVED: 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 📋 Review by `gpt-5.3-codex-high`SummaryThis PR establishes stable Code QualityThe implementation is clean and consistent with existing Streamlit patterns:
No blocking code-quality issues were found. Test CoverageCoverage is strong at unit level for the scope of this PR:
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 CompatibilityNo 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 & RiskNo 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
AccessibilityNo 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
VerdictAPPROVED: 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 📋 Review by `opus-4.6-thinking`SummaryThis PR sets a stable Key changes:
Code QualityThe code is well-structured and follows existing patterns consistently across all three container types (tabs, expander, popover). The same Minor observations:
Test CoveragePython tests (layouts_test.py): Excellent coverage with 17+ new/updated tests across the three containers:
Frontend tests (ElementsSetVisitor.test.ts): Good coverage with 4 new tests:
Gaps:
Backwards Compatibility
Setting No impact on Security & RiskNo security concerns identified. The changes are purely internal state management:
The External test recommendation
AccessibilityNo 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 Recommendations
VerdictAPPROVED: 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 This is an automated AI review by |
Co-authored-by: lawilby <[email protected]>
Co-authored-by: lawilby <[email protected]>
| # 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
sounds good, yeah I think we could track this somewhere, e.g. as an issue
- 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]>
| 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 |
There was a problem hiding this comment.
I think this needs an update based on the latest version
There was a problem hiding this comment.
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]>

Describe your changes
Sets a stable
Block.idviacompute_and_register_element_id(withkey_as_main_identity=True) forst.tabs,st.expander, andst.popoverwhenkeyis provided andon_change="ignore"(the default). This gives these containers a position-independent identity — the foundation for frontend state persistence across remounts.Block.idis set, not the element-level ID (tabContainer.id/expandable.id/popover.id), sosession_state[key]is not populated and the element is not registered as a widgetElementsSetVisitornow collectsBlock.idvalues and includes them in the active ID set passed toremoveInactive, preventingelementStatesentries from being garbage-collectedFollow-up PRs will wire up
useWidgetManagerElementStatein each component to persist active tab / expanded / open state across remounts, and applyst-key-*CSS classes.GitHub Issue Link (if applicable)
Addresses #8239
Testing Plan
block_proto.idset for passive keyed containers, absent without key, element-level ID not setElementsSetVisitorcollects block IDs from nested trees, ignores blocks without IDsContribution License Agreement
By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.