Skip to content

[DynamicContainers] Dynamic Expanders#13888

Merged
sfc-gh-lwilby merged 1 commit intodevelopfrom
02-10-_dynamiccontainers_dynamic_expanders
Feb 19, 2026
Merged

[DynamicContainers] Dynamic Expanders#13888
sfc-gh-lwilby merged 1 commit intodevelopfrom
02-10-_dynamiccontainers_dynamic_expanders

Conversation

@sfc-gh-lwilby
Copy link
Copy Markdown
Collaborator

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

Demo App: https://dynamic-expander.streamlit.app/

Describe your changes

Adds key and on_change parameters to st.expander, enabling stateful dynamic expanders that persist user toggle state across reruns. Reworks the frontend animation system with a useDetailsAnimation hook and animateHeight utility.

  • on_change="rerun": Registers the expander as a widget so toggling triggers a rerun; expanded state is accessible via st.session_state[key] and .open
  • on_change="ignore" (default): No state tracking — preserves current non-widget behavior
  • ResizeObserver-based animation: Replaces the Safari two-step repaint workaround with immediate measurement + ResizeObserver fallback, smooth mid-animation interruption via getBoundingClientRect(), and nullable expanded handling for backend-synced state changes
  • Widget-consistent loading skeleton: Uses SquareSkeleton (same as other widgets) instead of TextLineSkeleton for the loading placeholder when opening an empty dynamic expander

Testing Plan

  • Unit Tests (JS and/or Python)
    • useDetailsAnimation.test.ts — toggle behavior, backend sync, nullable handling, ResizeObserver lifecycle, rapid double-toggle, cleanup
    • animateHeight.test.ts — animation creation, custom options, finish/cancel lifecycle
    • Expander.test.tsx — widget mode rendering, skeleton, CSS key class, onToggle callback wiring
    • layouts_test.pyon_change validation, widget registration, session state access, widget ID assignment, ignore-mode negative checks
  • E2E Tests
    • st_expander_test.py — stateful expand/collapse reruns, nested expanders, ignore-mode no-rerun verification

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 10, 2026

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 10, 2026

✅ PR preview is ready!

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

@snyk-io
Copy link
Copy Markdown
Contributor

snyk-io bot commented Feb 10, 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.

@sfc-gh-lwilby sfc-gh-lwilby added change:feature PR contains new feature or enhancement implementation impact:users PR changes affect end users security-assessment-completed labels Feb 10, 2026
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-10-_dynamiccontainers_dynamic_expanders branch from 49f8b53 to e2b4c18 Compare February 11, 2026 13:32
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-09-_dynamiccontainers_refactoring_of_expander_to_support_more_complex_state_handling branch from 6c337d2 to 85202bd Compare February 11, 2026 13:32
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-10-_dynamiccontainers_dynamic_expanders branch from e2b4c18 to aa1c448 Compare February 11, 2026 14:52
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-09-_dynamiccontainers_refactoring_of_expander_to_support_more_complex_state_handling branch from 85202bd to 3f42201 Compare February 11, 2026 14:52
@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 implements "dynamic expanders" for Streamlit, adding on_change and key parameters to st.expander(). When on_change="rerun" is set, the expander registers as a widget, tracks its open/closed state in session_state, and triggers a full app rerun on toggle. This enables a "lazy execution" pattern where content inside an expander only runs when it's open. The changes span:

  • Backend (lib/streamlit/elements/layouts.py): New key and on_change parameters, widget registration via register_widget, _ExpanderSerde dataclass, and setting .open on the returned ExpanderContainer.
  • Frontend (Expander.tsx, Block.tsx): The Expander component now accepts optional widgetMgr, blockId, and fragmentId props. When in widget mode, it calls setBoolValue on toggle to notify the backend. It also shows a loading skeleton while waiting for lazy content.
  • E2E tests: Comprehensive tests for lazy execution, programmatic control, nested dynamic expanders, and state preloading.
  • Python unit tests: Covers on_change validation, .open attribute, session state access, block ID generation, and widget state.

Code Quality

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

Backend:

  • The _ExpanderSerde dataclass (lines 65-75 in layouts.py) follows the established serde pattern used by other widgets.
  • The widget registration flow mirrors other stateful elements (checkbox, etc.) with proper use of check_widget_policies, compute_and_register_element_id, and register_widget.
  • The elif key: branch at line 960 correctly handles the case where a key is provided without on_change="rerun", generating an element ID for CSS class purposes only (consistent with existing behavior for other elements).
  • The docstring for the new parameters is thorough and follows the numpydoc style used elsewhere.

Frontend:

  • The Expander component cleanly separates widget mode from non-widget mode using const isWidget = Boolean(widgetMgr && blockId).
  • The loading skeleton pattern (showing a skeleton while waiting for lazy content) is a good UX choice. The two useEffect hooks for clearing loading state (lines 114-134) are justified by their comments—they sync with external systems (backend content arrival and script run completion).
  • The Block.tsx changes properly pass widgetMgr, blockId, and fragmentId to the Expander component.

Minor observations:

  1. In layouts.py line 913, the validation if on_change not in {"ignore", "rerun"} is correct, though the type system already constrains this to Literal["ignore", "rerun"]. The runtime check is still valuable since Python doesn't enforce Literal at runtime.

  2. The _ExpanderSerde class is prefixed with underscore, consistent with the project convention for module-private symbols.

Test Coverage

Python unit tests (10 new tests): Comprehensive coverage including:

  • Invalid on_change validation
  • .open attribute for both expanded=True and expanded=False
  • Block ID generation with and without on_change
  • Session state accessibility
  • Widget state propagation to proto

Frontend unit tests (7 new tests): Good coverage of widget mode behavior:

  • setBoolValue called on toggle
  • No setBoolValue when widgetMgr is absent
  • Loading skeleton on empty widget expander
  • No skeleton for non-widget expander
  • Skeleton clearing on content arrival and script run completion
  • CSS class from blockId

E2E tests (5 new tests): Thorough end-to-end coverage:

  • Lazy execution counting
  • Programmatic control via session state
  • Nested dynamic expanders (both click-based and programmatic)
  • State preloading (setting inner state before outer is rendered)

The E2E tests include good negative assertions (e.g., verifying execution counts don't change when expanders are closed). The NUMBER_OF_EXPANDERS constant was correctly updated from 15 to 18.

One gap noted: There is no test for using on_change="rerun" inside a @st.fragment, which is mentioned as a common context to verify in the E2E testing guidelines. However, this may be covered in a future PR or may not be relevant at this stage.

Backwards Compatibility

This PR is fully backwards compatible:

  • The new key and on_change parameters are keyword-only with safe defaults (None and "ignore" respectively).
  • The default behavior (on_change="ignore") preserves the existing non-widget behavior where .open returns None.
  • Existing expanders without key or on_change are completely unaffected—no element ID is set, no widget registration occurs.
  • The frontend gracefully handles both widget and non-widget modes—when widgetMgr and blockId are absent, the component behaves identically to before.
  • The expanded parameter continues to work as before for initial state.

Security & Risk

  • No security concerns identified. The widget registration follows the established pattern with proper policy checks (check_widget_policies).
  • Low regression risk. The changes are additive and gated behind on_change="rerun". The default code path is unchanged.
  • Potential edge case: When on_change="rerun" is used without a key, the auto-generated element ID includes parameters like label, expanded, icon, width, and on_change. If a user changes the label dynamically, the widget ID would change, potentially losing state. This is consistent with how other auto-keyed widgets behave, so it's not a bug, but worth noting.

Accessibility

  • The Expander component continues to use semantic <details>/<summary> HTML elements, which are inherently accessible.
  • The inert attribute is still properly set on collapsed content to exclude it from screen reader traversal and find-in-page searches.
  • The loading skeleton has a data-testid for test targeting but would benefit from an aria-label or role="status" for screen readers to announce loading state. However, this is a pre-existing pattern in the codebase, not introduced by this PR.
  • No new interactive elements are added without accessible names.

Recommendations

  1. Consider testing in @st.fragment context: The E2E testing guidelines recommend verifying widget behavior within a @st.fragment. Consider adding a test or documenting that this will be covered separately.

  2. Minor: docstring note about lazy execution is now outdated. The docstring at line 786-789 still says "All content within the expander is computed and sent to the frontend, even if the expander is closed." This is no longer true when on_change="rerun" is used with conditional execution. Consider updating this note to mention the new behavior, or adding a note that this applies only when on_change="ignore".

  3. Consider adding a typing test: The lib/streamlit/AGENTS.md mentions typing tests in lib/tests/streamlit/typing/ for public API. Since key and on_change are new public API parameters, a typing test (e.g., expander_types.py) would catch future typing regressions.

Verdict

APPROVED: Well-implemented feature with thorough test coverage, full backwards compatibility, and clean code that follows established patterns. The recommendations above are minor improvements that could be addressed in follow-up PRs.


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

@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-10-_dynamiccontainers_dynamic_expanders branch from aa1c448 to 2997011 Compare February 11, 2026 16:03
@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from 02-09-_dynamiccontainers_refactoring_of_expander_to_support_more_complex_state_handling to graphite-base/13888 February 12, 2026 17:49
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-10-_dynamiccontainers_dynamic_expanders branch from 2997011 to 386c302 Compare February 12, 2026 17:49
@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from graphite-base/13888 to 02-09-_dynamiccontainers_refactoring_of_expander_to_support_more_complex_state_handling February 12, 2026 17:49
@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from 02-09-_dynamiccontainers_refactoring_of_expander_to_support_more_complex_state_handling to graphite-base/13888 February 12, 2026 18:19
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-10-_dynamiccontainers_dynamic_expanders branch from 386c302 to 9acc792 Compare February 12, 2026 18:19
@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from graphite-base/13888 to 02-09-_dynamiccontainers_refactoring_of_expander_to_support_more_complex_state_handling February 12, 2026 18:19
@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from 02-09-_dynamiccontainers_refactoring_of_expander_to_support_more_complex_state_handling to graphite-base/13888 February 13, 2026 10:39
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-10-_dynamiccontainers_dynamic_expanders branch from 9acc792 to 593d630 Compare February 13, 2026 10:39
@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from graphite-base/13888 to lwilby/expander-animation-improvements February 13, 2026 10:39
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the lwilby/expander-animation-improvements branch from 56859f6 to 0c911d0 Compare February 13, 2026 21:22
Base automatically changed from lwilby/expander-animation-improvements to develop February 14, 2026 11:09
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-10-_dynamiccontainers_dynamic_expanders branch from b6d595f to f3e158b Compare February 16, 2026 12:14
Copilot AI review requested due to automatic review settings February 16, 2026 12:14
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 adds dynamic expander functionality to Streamlit, enabling stateful expanders that can trigger reruns and support lazy content execution. The implementation introduces key and on_change parameters to st.expander, allowing developers to programmatically control expander state via session state.

Changes:

  • Adds on_change="rerun" mode for dynamic expanders that trigger app reruns on toggle, with state accessible via st.session_state[key] and the .open attribute
  • Reworks the frontend animation system with improved ResizeObserver-based content size tracking, reducing the debounce delay from 50ms to 10ms for more responsive animations
  • Implements widget-consistent loading skeleton using SquareSkeleton when opening empty dynamic expanders

Reviewed changes

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

Show a summary per file
File Description
proto/streamlit/proto/Block.proto Adds optional widget_id field to Expandable message for dynamic expander widget identification
lib/streamlit/elements/layouts.py Implements expander widget registration logic, validation for on_change parameter, and session state integration
lib/tests/streamlit/elements/layouts_test.py Comprehensive unit tests covering widget registration, state management, and ignore mode behavior
lib/streamlit/elements/lib/mutable_expander_container.py Not in diff but referenced - defines .open property on ExpanderContainer
frontend/lib/src/components/elements/Expander/useDetailsAnimation.ts Updates RESIZE_DEBOUNCE_MS from 50ms to 10ms with detailed rationale in comments
frontend/lib/src/components/elements/Expander/Expander.tsx Adds widget mode handling, loading skeleton logic, CSS key class application, and backend toggle notification
frontend/lib/src/components/elements/Expander/Expander.test.tsx Comprehensive frontend tests for widget mode, loading skeleton, and CSS class behavior
frontend/lib/src/components/core/Block/Block.tsx Passes widget props (widgetMgr, widgetId, blockId, empty, fragmentId) to Expander component
e2e_playwright/st_expander.py Test app demonstrating lazy execution, programmatic control, nested expanders, and ignore mode
e2e_playwright/st_expander_test.py E2E tests verifying dynamic expander behavior, state persistence, and non-rerun behavior in ignore mode
specs/2026-01-14-dynamic-tabs-expander/product-spec.md Updates key parameter type to include int for consistency with other widgets

@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-10-_dynamiccontainers_dynamic_expanders branch 3 times, most recently from 6a9159e to 3a22a4a Compare February 16, 2026 16:53
@github-actions
Copy link
Copy Markdown
Contributor

Summary

Adds stateful st.expander behavior via on_change="rerun" and key support in the backend, plus a new frontend animation hook/utility and loading skeleton handling for dynamic expanders. Updates the protobuf to carry a widget id for expanders and extends unit/e2e coverage for the new behavior.

Code Quality

No blocking issues found. The backend/widget registration flow in lib/streamlit/elements/layouts.py and the frontend animation logic in frontend/lib/src/components/elements/Expander/useDetailsAnimation.ts are well-factored and consistent with existing patterns.

Test Coverage

Good coverage: Python unit tests in lib/tests/streamlit/elements/layouts_test.py, frontend unit tests for the hook/utility and widget mode behavior (useDetailsAnimation.test.ts, animateHeight.test.ts, Expander.test.tsx), and e2e scenarios in e2e_playwright/st_expander_test.py that validate dynamic behavior and ignore-mode non-rerun.

Backwards Compatibility

Default behavior remains on_change="ignore", so existing expanders should behave as before. Stateful behavior is opt-in via on_change="rerun" and does not affect legacy usage.

Security & Risk

No security concerns identified. The main risk is visual/behavioral regression around expander animations and empty content loading, which is mitigated by unit and e2e coverage.

Accessibility

No new accessibility regressions noted. The component continues to use semantic <details>/<summary> with focus-visible styling, and inert is applied only to collapsed content.

Recommendations

  1. If the new min-height/skeleton behavior changes the empty-expander visuals, ensure related snapshots are updated in the e2e suite.

Verdict

APPROVED: Changes look safe, backwards compatible, and well-tested.


This is an automated AI review by gpt-5.2-codex-high. Please verify the feedback and use your judgment.


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.

We might also want to call element_id = compute_and_register_element_id if key is provided, so that it gets added to block_proto.id and thereby added as a CSS key. Potentially, it might make sense to always calculate the ID to help with #8239, but always calling has the downside of throwing an exception of there are two identical expanders -> only doing it if key is set might be a safer option and also provides a way to resolve: #8239

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.

I do want to add the CSS key, but I just plan on doing this in a separate PR because I think it warrants some attention to how well it works for styling for users. Right now it will be added to an inner div that wraps the content in the expander (due to the container implementation). Users might expect it to wrap the entire expander.

That's a good point about making it a widget if the user provides a key, since this is a net new feature. I believe I was thinking of that when I wrote the spec, so thank you for brining it up again!

Copy link
Copy Markdown
Collaborator

@sfc-gh-lmasuch sfc-gh-lmasuch left a comment

Choose a reason for hiding this comment

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

LGTM 👍 with one comment about ID computation. But maybe better to add as a follow up.

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
Copy link
Copy Markdown
Collaborator Author

LGTM 👍 with one comment about ID computation. But maybe better to add as a follow up.

Yeah, maybe I can make this change along with handling the CSS class + key, but prior to release cutoff so it won't introduce a change for users.

@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-10-_dynamiccontainers_dynamic_expanders branch 3 times, most recently from 13a2826 to c3f6bce Compare February 18, 2026 14:00
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-10-_dynamiccontainers_dynamic_expanders branch from c3f6bce to 8346511 Compare February 18, 2026 16:24
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-10-_dynamiccontainers_dynamic_expanders branch 2 times, most recently from 475467e to 357b896 Compare February 19, 2026 15:23
Bumps [immer](https://github.com/immerjs/immer) from 11.1.3 to 11.1.4.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/immerjs/immer/releases">immer's
releases</a>.</em></p>
<blockquote>
<h2>v11.1.4</h2>
<h2><a
href="https://github.com/immerjs/immer/compare/v11.1.3...v11.1.4">11.1.4</a>
(2026-02-10)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>handle nested proxies after spreading and inserting into an array
(<a
href="https://github.com/immerjs/immer/commit/90a77655af5a9dd3fbf7688f95952d32511b430c">90a7765</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/immerjs/immer/commit/cdccf1a6d0d349ad587a736f0eb8a3da43fc7747"><code>cdccf1a</code></a>
Merge pull request <a
href="https://redirect.github.com/immerjs/immer/issues/1210">#1210</a>
from immerjs/bugfix/1209-array-plugin-nested-drafts</li>
<li><a
href="https://github.com/immerjs/immer/commit/90a77655af5a9dd3fbf7688f95952d32511b430c"><code>90a7765</code></a>
fix: handle nested proxies after spreading and inserting into an
array</li>
<li>See full diff in <a
href="https://github.com/immerjs/immer/compare/v11.1.3...v11.1.4">compare
view</a></li>
</ul>
</details>
<br />

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=immer&package-manager=npm_and_yarn&previous-version=11.1.3&new-version=11.1.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)

</details>

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Lukas Masuch <[email protected]>

Co-authored-by: lawilby <[email protected]>
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the 02-10-_dynamiccontainers_dynamic_expanders branch from 357b896 to 94b8adc Compare February 19, 2026 17:41
@sfc-gh-lwilby sfc-gh-lwilby added the ai-review If applied to PR or issue will run AI review workflow label Feb 19, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Feb 19, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR adds key and on_change parameters to st.expander, enabling stateful "dynamic" expanders. When on_change="rerun" is set, the expander registers as a widget: toggling triggers a rerun, expanded state is accessible via st.session_state[key] and the .open attribute, and programmatic control is supported. The default on_change="ignore" preserves existing non-widget behavior. The implementation follows the established pattern from st.popover and st.tabs, which already support the same on_change mechanism.

Key changes:

  • Proto: Added optional string id field to Block.Expandable to signal widget mode.
  • Backend: Widget registration (_ExpanderSerde, register_widget, check_widget_policies) when on_change="rerun".
  • Frontend: Expander.tsx gains widgetMgr, blockId, fragmentId props; useDetailsAnimation hook gains onToggle callback.
  • Block.tsx: Threads widgetMgr, blockId, and fragmentId to the Expander component.
  • Tests: Comprehensive Python unit tests, frontend unit tests, and E2E tests.

Code Quality

The implementation is well-structured and follows established codebase patterns closely.

Backend:

  • The widget registration logic mirrors the popover and tabs implementations, ensuring consistency.
  • Proper use of check_widget_policies, compute_and_register_element_id, and register_widget.
  • Good validation with StreamlitValueError for invalid on_change values.
  • The _ExpanderSerde dataclass (pre-existing) is reused cleanly.

Frontend:

  • The isWidget determination from element.id is clean and avoids false positives when only blockId is set for CSS key styling.
  • handleWidgetToggle uses useCallback with correct dependency arrays.
  • useDetailsAnimation hook changes are minimal and well-targeted — only adding the onToggle callback plumbing.
  • The animateHeight utility and its test are well-factored with clear cancel/finish lifecycle semantics.

Minor issues:

  1. lib/streamlit/elements/layouts.py line 1066–1067: Missing blank line between the end of the if is_stateful: block and expandable_proto = BlockProto.Expandable(). A blank line here would improve readability and separate the two logical sections:
            current_expanded = expander_state.value
        expandable_proto = BlockProto.Expandable()
        expandable_proto.expanded = current_expanded
  1. The docstring .. note:: at lines 896–898 says "All content within the expander is computed and sent to the frontend, even if the expander is closed." This is now misleading when on_change="rerun" is combined with the if exp.open: pattern (the primary use case for the new feature), where content is explicitly not computed when closed. Consider updating or qualifying this note.

Test Coverage

Test coverage is thorough and well-organized:

Python unit tests (layouts_test.py): 12 new tests covering:

  • Invalid on_change validation
  • .open returns correct bool/None values for both modes
  • Block ID and expandable ID assignment for rerun vs ignore
  • Session state accessibility with explicit key
  • Widget state driving the proto expanded value
  • Good negative tests: key without on_change does not set block ID; ignore mode does not set IDs.

Frontend unit tests:

  • Expander.test.tsx: Widget mode setBoolValue calls, non-widget mode guard, CSS class from blockId, negative assertions.
  • useDetailsAnimation.test.ts: Comprehensive coverage of initial state, toggle, rapid double-toggle, backend sync (including null/undefined ClearField), label change reset, ResizeObserver lifecycle, debounce, threshold, zero-content fallback path, cleanup.
  • animateHeight.test.ts: Animation creation, custom options, finish/cancel lifecycle and style cleanup semantics.

E2E tests (st_expander_test.py): 6 new tests covering lazy execution, programmatic control, nested expanders (click + button), state preloading across mount/unmount cycles, and ignore-mode no-rerun verification.

Gaps (non-blocking):

  • No typing test additions in lib/tests/streamlit/typing/expander_container_types.py for the new on_change and key parameters. Consider adding assert_type checks for expander("Test", on_change="rerun", key="k") to catch typing regressions.
  • No @st.fragment E2E test for the dynamic expander (the fragmentId prop is threaded through, but not tested end-to-end). This could be a follow-up.
  • Per the E2E AGENTS.md guidance on preferring aggregated scenario tests: the nested tests (test_dynamic_expander_nested and test_dynamic_expander_nested_programmatic_control) cover overlapping setup and could be consolidated. However, the distinct interaction patterns (UI click vs button) justify separate test functions for clarity.

Backwards Compatibility

No breaking changes. Backwards compatibility is well-maintained:

  • on_change defaults to "ignore", preserving the current non-widget behavior for all existing code.
  • key without on_change="rerun" has no effect (tested explicitly).
  • .open property continues to return None by default (tested).
  • The proto field id is optional — existing messages without it are unaffected.
  • The ExpanderContainer class is unchanged in its public interface; the .open property was already bool | None.

Security & Risk

No security concerns identified. The widget registration follows the same established patterns used by popover and tabs. The check_widget_policies call ensures proper policy enforcement (e.g., preventing use inside @st.cache_data).

Low regression risk: the default behavior is unchanged, and the new code paths are fully gated behind on_change="rerun".

Accessibility

Accessibility is properly maintained:

  • The <summary> element is semantically correct for the toggle interaction and has proper :focus-visible styling via StyledSummary.
  • The inert attribute on StyledDetailsPanel correctly excludes collapsed content from assistive technology and browser find-in-page, and is properly toggled on expand/collapse.
  • No new aria-hidden usage on interactive wrappers.
  • Keyboard navigation is preserved through native <summary> behavior.

Recommendations

  1. Add a blank line after line 1066 in lib/streamlit/elements/layouts.py to separate the if is_stateful: block from the proto construction.
  2. Update or qualify the .. note:: in the expander docstring (lines 896–898) to mention that with on_change="rerun", content can be conditionally computed using if exp.open:.
  3. Consider adding typing tests in expander_container_types.py for the new on_change and key parameters.
  4. Consider adding a @st.fragment E2E test for the dynamic expander in a follow-up PR.

Verdict

APPROVED: Well-implemented feature following established widget patterns with comprehensive test coverage and full backwards compatibility. The minor suggestions above are non-blocking improvements.


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

@sfc-gh-lwilby sfc-gh-lwilby merged commit b1364c6 into develop Feb 19, 2026
58 checks passed
@sfc-gh-lwilby sfc-gh-lwilby deleted the 02-10-_dynamiccontainers_dynamic_expanders branch February 19, 2026 19:28
lukasmasuch pushed a commit that referenced this pull request Feb 20, 2026
Demo App: https://dynamic-expander.streamlit.app/

## Describe your changes

Adds `key` and `on_change` parameters to `st.expander`, enabling
stateful dynamic expanders that persist user toggle state across reruns.
Reworks the frontend animation system with a `useDetailsAnimation` hook
and `animateHeight` utility.

- **`on_change="rerun"`**: Registers the expander as a widget so
toggling triggers a rerun; expanded state is accessible via
`st.session_state[key]` and `.open`
- **`on_change="ignore"` (default)**: No state tracking — preserves
current non-widget behavior
- **ResizeObserver-based animation**: Replaces the Safari two-step
repaint workaround with immediate measurement + `ResizeObserver`
fallback, smooth mid-animation interruption via
`getBoundingClientRect()`, and nullable `expanded` handling for
backend-synced state changes
- **Widget-consistent loading skeleton**: Uses `SquareSkeleton` (same as
other widgets) instead of `TextLineSkeleton` for the loading placeholder
when opening an empty dynamic expander

## Testing Plan

- [x] Unit Tests (JS and/or Python)
- `useDetailsAnimation.test.ts` — toggle behavior, backend sync,
nullable handling, ResizeObserver lifecycle, rapid double-toggle,
cleanup
- `animateHeight.test.ts` — animation creation, custom options,
finish/cancel lifecycle
- `Expander.test.tsx` — widget mode rendering, skeleton, CSS key class,
`onToggle` callback wiring
- `layouts_test.py` — `on_change` validation, widget registration,
session state access, widget ID assignment, ignore-mode negative checks
- [x] E2E Tests
- `st_expander_test.py` — stateful expand/collapse reruns, nested
expanders, ignore-mode no-rerun verification

---

**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: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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.

4 participants