[refactor] improve Expander animation with ResizeObserver, smooth interruption, and nullable expanded handling#13934
Conversation
✅ 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. |
✅ PR preview is ready!
|
SummaryThis PR refactors the
Code QualityThe code is well-structured with clear separation of concerns ( Positive observations:
Minor observations:
Test CoverageThe hook tests (
Gaps:
Backwards Compatibility
Security & RiskNo security concerns. The changes are purely frontend animation logic with no network, auth, or data handling implications. Regression risks:
AccessibilityNo accessibility regressions. The component continues to use semantic Recommendations
VerdictCHANGES REQUESTED: The code quality and architecture are solid, but the ResizeObserver — the primary new feature — has no test coverage, and the Safari workaround removal needs explicit verification. Please add ResizeObserver-related unit tests and confirm Safari behavior before merging. This is an automated AI review by |
19a78c9 to
b986bc9
Compare
87f055d to
66563b6
Compare
b986bc9 to
91120e2
Compare
SummaryThis PR refactors the Expander animation logic in
Files changed:
Code QualityThe code is well-structured, thoroughly documented, and follows existing patterns in the codebase. Strengths:
Issues:
Test CoverageTest coverage is comprehensive and well-organized:
The rapid double-toggle test is particularly valuable as it validates the Minor gap: There is no direct test for the Backwards CompatibilityNo breaking changes. The Security & RiskNo security concerns. This is a pure frontend animation refactor with no changes to data handling, authentication, or external communication. Regression risk is low:
AccessibilityNo accessibility impact. The component continues to use semantic Recommendations
VerdictCHANGES REQUESTED: The code is well-designed and thoroughly tested, but the missing This is an automated AI review by |
91120e2 to
28ad5f0
Compare
SummaryThis PR replaces the Safari two-step repaint workaround in the Expander animation with a
Files changed: Code QualityThe code is well-structured and follows React best practices:
Minor issue:
// eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing with external backend state (label change = new expander)
setIsOpen(newOpen)vs: if (backendExpanded !== isOpenRef.current) {
isOpenRef.current = backendExpanded
setIsOpen(backendExpanded)
animateTo(backendExpanded)Test CoverageTest coverage is comprehensive and well-organized:
The The No significant gaps identified. E2E tests are not included but this is acceptable since the changes are animation refinements that are difficult to assert in Playwright, and the author confirmed manual Safari verification. Backwards CompatibilityNo breaking changes for end users:
Security & RiskNo security concerns. The changes are purely frontend animation logic with no network, storage, or user data implications. Risk assessment:
AccessibilityNo accessibility regressions:
Recommendations
VerdictAPPROVED: Well-engineered refactoring that replaces a fragile Safari workaround with a robust ResizeObserver approach. The code is clean, well-documented, and thoroughly tested with only one minor eslint comment inconsistency to address. This is an automated AI review by |
📈 Frontend coverage change detectedThe frontend unit test (vitest) coverage has increased by 0.0500%
🎉 Great job on improving test coverage! |
28ad5f0 to
b92c66a
Compare
66563b6 to
8bbe616
Compare
| import { BORDER_SIZE } from "./styled-components" | ||
|
|
||
| /** | ||
| * Delay before measuring content height for the Safari repaint workaround (ms). |
There was a problem hiding this comment.
This is now handled with a resizeObserver which also supports dynamic content arriving at a delay after rerun.
There was a problem hiding this comment.
Pull request overview
Refactors Expander open/close animation into a dedicated useDetailsAnimation hook with ResizeObserver support, aiming to improve Safari behavior, allow smooth mid-animation interruptions, and correctly handle nullable backend expanded updates.
Changes:
- Reworked
<details>open/close logic to use measured heights + cancelable animations with interruption support. - Replaced the Safari timeout repaint workaround with a debounced
ResizeObserver-based resize sync. - Added explicit handling for
null/undefinedbackend expanded state and introduced an optionalonTogglecallback; updated/expanded unit tests.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
frontend/lib/src/components/elements/Expander/useDetailsAnimation.ts |
Major refactor: new animation orchestration (interruptions), backend sync logic, and ResizeObserver-driven resize handling. |
frontend/lib/src/components/elements/Expander/useDetailsAnimation.test.ts |
Updated tests for renamed option + added coverage for onToggle, nullable backend behavior, rapid toggles, and ResizeObserver behavior. |
frontend/lib/src/components/elements/Expander/Expander.tsx |
Updated hook callsite to use backendExpanded option name. |
| // Subsequent renders: sync with animation if backend state differs from local. | ||
| // After the null check above, backendExpanded is narrowed to boolean. | ||
| if (backendExpanded !== isOpenRef.current) { | ||
| isOpenRef.current = backendExpanded |
There was a problem hiding this comment.
In the backend sync effect, setIsOpen(backendExpanded) is called inside useEffect without the usual // eslint-disable-next-line react-hooks/set-state-in-effect suppression used elsewhere in the repo for intentional “sync from external source” updates. This is likely to fail linting (and is inconsistent with the nearby label-change branch).
Consider adding the suppression (with a brief justification) or refactoring the effect to avoid setting state there if feasible.
| isOpenRef.current = backendExpanded | |
| isOpenRef.current = backendExpanded | |
| // eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing with external backend state (expanded) |
| animationRef.current = animateHeight( | ||
| details, | ||
| currentHeight, | ||
| targetHeight, | ||
| { | ||
| onFinish: () => { | ||
| if (detailsRef.current) { | ||
| detailsRef.current.open = false | ||
| } | ||
| }, | ||
| } | ||
| ) |
There was a problem hiding this comment.
Closing sets details.open = false in the onFinish callback, but animateHeight clears style.height/style.overflow before calling onFinish. That ordering can cause a brief flicker where the element re-expands at the end of the close animation.
Consider ensuring open = false happens before style cleanup (e.g., reorder cleanup vs onFinish inside animateHeight, or add an option to control cleanup timing for the close case).
| animationRef.current = animateHeight( | |
| details, | |
| currentHeight, | |
| targetHeight, | |
| { | |
| onFinish: () => { | |
| if (detailsRef.current) { | |
| detailsRef.current.open = false | |
| } | |
| }, | |
| } | |
| ) | |
| // Set open=false before starting the animation to avoid any flicker | |
| // when animateHeight clears locked styles at the end of the transition. | |
| details.open = false | |
| animationRef.current = animateHeight(details, currentHeight, targetHeight) |
SummaryThis PR refactors expander animation logic into Code QualityOverall structure is cleaner than before (hook responsibilities are clearer, cancellation logic is centralized, and cleanup paths are explicit).
Test CoverageCoverage is broad for this hook and includes good edge cases (rapid toggles, nullable backend values, observer lifecycle, timeout cleanup). Backwards CompatibilityNo API-breaking changes for end users are visible in this diff, and the Security & RiskNo direct security concerns identified. AccessibilityNo new a11y regressions observed in this diff. Interaction remains on semantic Recommendations
VerdictCHANGES REQUESTED: The refactor is strong overall, but the new This is an automated AI review by |
| useEffect(() => { | ||
| const isInitialMount = !hasMountedRef.current | ||
| hasMountedRef.current = true | ||
|
|
||
| setIsOpen(prev => !prev) | ||
| const labelChanged = label !== prevLabelRef.current | ||
| prevLabelRef.current = label | ||
|
|
||
| const detailsEl = detailsRef.current | ||
| if (!detailsEl || !summaryRef.current) { | ||
| // If label changed, this is a "new expander" - cancel animations and reset. | ||
| // Clear any stale inline styles that cancelAnimation leaves behind. | ||
| if (labelChanged) { | ||
| cancelAnimation() | ||
| const newOpen = backendExpanded ?? false | ||
| isOpenRef.current = newOpen | ||
| // eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing with external backend state (label change = new expander) | ||
| setIsOpen(newOpen) | ||
| if (detailsRef.current) { | ||
| detailsRef.current.style.height = "" | ||
| detailsRef.current.style.overflow = "" | ||
| detailsRef.current.open = newOpen | ||
| } | ||
| return | ||
| } | ||
|
|
There was a problem hiding this comment.
I believe it's not recommended to use useEffect for changing the state - any state setter like setIsOpen. We have useExecuteWhenChanged as a utility in our codebase for these situations. I believe this follows the recommendations from: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes , but Bob knows more about this. You can also ask the agent if it would suggest to use useExecuteWhenChanged here instead.
There was a problem hiding this comment.
Here it is a bit different because there are DOM changes that are also happening conditioned on the same inputs as the setIsOpen state change, they can't go in useExecuteWhenChanged so moving the state change there would duplicate this decision logic in both places.
The tradeoff seemed better to keep the logic together since it is already fairly complex.
b92c66a to
56859f6
Compare
…tion, and nullable expanded handling
56859f6 to
0c911d0
Compare
SummaryThis PR refactors the
Additionally, an Code QualityThe code is well-structured and follows the project's conventions:
Minor observations:
Test CoverageTest coverage is thorough and well-designed:
The test quality follows the project conventions:
One minor note: the Backwards Compatibility
No breaking changes for end users. Security & RiskNo security concerns identified. The changes are purely frontend animation logic with no user data handling, network requests, or DOM injection vectors. Risk assessment:
AccessibilityNo accessibility changes in this PR. The Expander component's keyboard navigation, focus management, Recommendations
VerdictAPPROVED: Well-crafted refactoring that replaces a browser-specific hack with a standard This is an automated AI review by |
|
@lukasmasuch I updated this since you approved the PR because of this AI review finding:
It turned out that there were some changes from the original implementation in the prototype that were introduced while translating from the prototype and the iterations based on comments that started clearing the styles and broke the animation mechanism. See comment here explaining how this should work and why the styles are not cleared: https://github.com/streamlit/streamlit/pull/13934/changes#diff-19f972d3b751f843a462279619fbca28d48a88296182f43ce94888a758fe7e08R168-R173 |
…erruption, and nullable expanded handling (#13934) ## Describe your changes Extracts `<details>` open/close animation logic from `Expander.tsx` into a `useDetailsAnimation` hook and `animateHeight` utility, then replaces the Safari two-step repaint workaround with a `ResizeObserver`-based approach. - **ResizeObserver replaces Safari timeout hack**: Instead of animating 5px → waiting 100ms → measuring real height, the hook now measures immediately after `details.open = true` and falls back to a debounced `ResizeObserver` when content isn't yet painted (Safari) or loads asynchronously - **Smooth interruption**: Captures current computed height via `getBoundingClientRect()` before cancelling a running animation, so mid-animation toggles start from the actual visual position instead of jumping - **Nullable `expanded` handling**: Explicit branching for `null`/`undefined` (preserve current state), label changes (reset without animation), and initial mount (set DOM directly, skip animation) - **`onToggle` callback**: Hook accepts an optional callback for notifying callers on user toggle, preparing for widget mode in Dynamic Expanders ## Testing Plan - [x] Unit Tests (JS) - `useDetailsAnimation.test.ts` — initial state, toggle behavior, backend sync, nullable handling, label-based reset, `onToggle` callback, rapid double-toggle, cleanup, ResizeObserver (observe/disconnect lifecycle, closed-details guard, debouncing, close-mode guard, threshold check, unmount timeout cleanup) - `useDetailsAnimation.test.ts` — initial state, toggle behavior, backend sync, nullable handling, label-based reset, `onToggle` callback, rapid double-toggle, cleanup - `animateHeight.test.ts` — animation creation, custom options, finish/cancel lifecycle, promise resolution - [x] Manual verification (Safari) - Verified expand/collapse animation, rapid toggling, content resize via ResizeObserver, backend-synced state changes, nullable expanded handling, label swap reset, and nested expanders in Safari

Describe your changes
Extracts
<details>open/close animation logic fromExpander.tsxinto auseDetailsAnimationhook andanimateHeightutility, then replaces the Safari two-step repaint workaround with aResizeObserver-based approach.details.open = trueand falls back to a debouncedResizeObserverwhen content isn't yet painted (Safari) or loads asynchronouslygetBoundingClientRect()before cancelling a running animation, so mid-animation toggles start from the actual visual position instead of jumpingexpandedhandling: Explicit branching fornull/undefined(preserve current state), label changes (reset without animation), and initial mount (set DOM directly, skip animation)onTogglecallback: Hook accepts an optional callback for notifying callers on user toggle, preparing for widget mode in Dynamic ExpandersTesting Plan
useDetailsAnimation.test.ts— initial state, toggle behavior, backend sync, nullable handling, label-based reset,onTogglecallback, rapid double-toggle, cleanup, ResizeObserver (observe/disconnect lifecycle, closed-details guard, debouncing, close-mode guard, threshold check, unmount timeout cleanup)useDetailsAnimation.test.ts— initial state, toggle behavior, backend sync, nullable handling, label-based reset,onTogglecallback, rapid double-toggle, cleanupanimateHeight.test.ts— animation creation, custom options, finish/cancel lifecycle, promise resolution