Skip to content

[refactor] improve Expander animation with ResizeObserver, smooth interruption, and nullable expanded handling#13934

Merged
sfc-gh-lwilby merged 1 commit intodevelopfrom
lwilby/expander-animation-improvements
Feb 14, 2026
Merged

[refactor] improve Expander animation with ResizeObserver, smooth interruption, and nullable expanded handling#13934
sfc-gh-lwilby merged 1 commit intodevelopfrom
lwilby/expander-animation-improvements

Conversation

@sfc-gh-lwilby
Copy link
Copy Markdown
Collaborator

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

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

  • 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
  • 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

@snyk-io
Copy link
Copy Markdown
Contributor

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

Copy link
Copy Markdown
Collaborator Author

sfc-gh-lwilby commented Feb 12, 2026

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 12, 2026

✅ PR preview is ready!

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

@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from lwilby/expander-refactor-extract-hook to graphite-base/13934 February 13, 2026 14:07
@sfc-gh-lwilby sfc-gh-lwilby changed the title feat: improve Expander animation with ResizeObserver, smooth interruption, and nullable expanded handling [refactor] improve Expander animation with ResizeObserver, smooth interruption, and nullable expanded handling Feb 13, 2026
@sfc-gh-lwilby sfc-gh-lwilby added the ai-review If applied to PR or issue will run AI review workflow label Feb 13, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Feb 13, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR refactors the useDetailsAnimation hook in the Expander component with several improvements:

  1. Renamed initialExpanded to backendExpanded to better reflect that this value is a live, controlled prop from the backend proto, not just an initial value.
  2. Replaced Safari two-step repaint workaround with ResizeObserver — the old code animated 5px, waited 100ms, then animated to the real height. The new code uses ResizeObserver to detect content size changes and smoothly animate them.
  3. Improved animation interruption — uses isOpenRef to track intended state, enabling correct behavior on rapid toggles.
  4. Added onToggle callback — optional callback for notifying callers when the user toggles (preparatory for widget mode).
  5. Added hasMountedRef to skip animation on initial mount.
  6. Simplified inline mouse handlers in Expander.tsx.

Code Quality

The code is well-structured with clear separation of concerns (animateTo, animateResize, backend sync effect, ResizeObserver effect, toggle handler). The comments are thorough, particularly around the read/write phase batching for reflow minimization.

Positive observations:

  • Correct use of useCallback with stable dependency arrays; animateResize and animateTo are referentially stable since they only depend on cancelAnimation (which has [] deps).
  • Proper cleanup in all effects: ResizeObserver.disconnect(), clearTimeout, cancelAnimation.
  • The isOpenRef pattern correctly handles rapid double-toggles — verified by a dedicated test.
  • The animateHeight utility correctly distinguishes cancel vs. finish semantics (styles cleared on finish, not on cancel), which enables smooth interruption.
  • ESLint disable comments are well-scoped and explain why reflow-triggering reads are necessary.

Minor observations:

  1. isOpenRef.current = isOpen during render (line 96 of useDetailsAnimation.ts): Setting a ref during render is technically a side effect. This is a widely accepted pattern when the ref is only read in event handlers/effects, but it's worth noting as it deviates from strict React guidelines.

  2. Mount effect order dependency (lines 212–262 of useDetailsAnimation.ts): The sync effect and mount effect both set detailsRef.current.open. On initial mount, the sync effect correctly early-returns (state matches or backendExpanded is nullish), and the mount effect handles initial DOM state. This works correctly but the implicit ordering dependency between effects is subtle — a brief comment noting the expected execution order would help future maintainers.

  3. Safari workaround removal: The old code had a specific workaround for Safari where <details> content isn't painted synchronously when opened. The new approach relies on the contentHeight > 0 check (line 153) and ResizeObserver to animate once content appears. This is architecturally cleaner, but the PR description doesn't mention whether this was verified on Safari. If Safari still delays painting content in <details>, users might briefly see the expander at collapsed height before the ResizeObserver triggers the animation.

Test Coverage

The hook tests (useDetailsAnimation.test.ts) provide good coverage of state logic:

  • Initial state for all backendExpanded values (true, false, null, undefined)
  • Toggle behavior (both directions, rapid double-toggle)
  • onToggle callback (present and absent)
  • preventDefault behavior
  • Backend sync, ClearField preservation, undefined preservation
  • Label change reset
  • Clean unmount

Gaps:

  • No tests for ResizeObserver behavior — This is the most significant new feature in the PR, yet it has zero test coverage. While DOM-dependent behavior is harder to unit test, the ResizeObserver callback logic (debouncing, threshold check, only-when-open guard, closing-mode guard) could be tested with mocked ResizeObserver and getBoundingClientRect.
  • No tests for animation behavior (animateTo, animateResize) — These rely on DOM APIs not available in jsdom, so they would need mocking or are better suited for E2E tests.
  • No new E2E tests — Given this is a behavioral refactoring of animation logic, E2E tests (particularly on WebKit/Safari) would provide confidence that the Safari workaround removal doesn't cause regressions.
  • The PR description's testing plan section is empty — It's unclear what manual or automated testing was performed.

Backwards Compatibility

  • The initialExpandedbackendExpanded rename is internal to the hook. The Expander.tsx component's public interface (ExpanderProps) is unchanged. No breaking changes for external consumers.
  • The onToggle callback is optional and not yet used by the Expander component. This is forward-compatible for widget mode.
  • The animation behavior change (removing Safari workaround, adding ResizeObserver-based resize animation) is a UX-visible change, but not an API change.

Security & Risk

No security concerns. The changes are purely frontend animation logic with no network, auth, or data handling implications.

Regression risks:

  • Safari animation — The removed Safari repaint workaround (SAFARI_REPAINT_DELAY_MS, SAFARI_REPAINT_HEIGHT) was specifically added to handle a known Safari rendering issue. Removing it in favor of ResizeObserver is architecturally better, but without explicit Safari testing confirmation, there's some regression risk.
  • ResizeObserver during closing — The guard if (!isOpenRef.current) return in the ResizeObserver callback (line 287) prevents interference during close animation. This depends on isOpenRef being correctly set before the ResizeObserver fires, which is ensured by the handleToggle synchronous ref update (line 335). This seems correct.

Accessibility

No accessibility regressions. The component continues to use semantic <details>/<summary> elements, preserves the inert attribute for collapsed content (hiding it from find-in-page and assistive tech), and maintains keyboard focus styles via :focus-visible.

Recommendations

  1. Add unit tests for ResizeObserver behavior: Mock ResizeObserver and getBoundingClientRect to test the debounce logic, the 5px threshold, the "only when open" guard, and the "don't interfere during closing" guard. This is the most significant new feature with zero test coverage.

  2. Confirm Safari behavior: The PR removes a Safari-specific workaround. Please verify on Safari (or WebKit in Playwright) that the expand animation still works smoothly. Add a note to the PR description about the testing performed.

  3. Fill in the PR description: The PR body is currently empty (template only). Document the motivation, the Safari workaround replacement rationale, and any manual testing done.

  4. Minor: Consider a comment about effect ordering at line 254 in useDetailsAnimation.ts — something like "// Runs after the sync effect above — handles initial DOM state before marking as mounted" would make the implicit ordering explicit for future readers.

Verdict

CHANGES 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 opus-4.6-thinking.

@github-actions github-actions bot added the do-not-merge PR is blocked from merging label Feb 13, 2026
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the lwilby/expander-animation-improvements branch from 19a78c9 to b986bc9 Compare February 13, 2026 15:50
@sfc-gh-lwilby sfc-gh-lwilby changed the base branch from graphite-base/13934 to lwilby/expander-refactor-extract-hook February 13, 2026 15:50
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the lwilby/expander-animation-improvements branch from b986bc9 to 91120e2 Compare February 13, 2026 16:33
@sfc-gh-lwilby sfc-gh-lwilby added the ai-review If applied to PR or issue will run AI review workflow label Feb 13, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Feb 13, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR refactors the Expander animation logic in useDetailsAnimation to:

  1. Replace the Safari two-step repaint workaround (setTimeout + 5px trick) with a ResizeObserver-based approach that detects when content renders asynchronously and animates smoothly.
  2. Support smooth animation interruption by capturing the current computed height via getBoundingClientRect() before cancelling a running animation, so mid-animation toggles start from the actual visual position.
  3. Handle nullable expanded explicitly with clear branching for null/undefined (preserve state), label changes (reset without animation), and initial mount (set DOM directly, skip animation).
  4. Add an onToggle callback parameter to prepare for widget mode in Dynamic Expanders.
  5. Rename initialExpanded to backendExpanded to better reflect the prop's semantics (it's not just an initial value, it can change over time).

Files changed:

  • frontend/lib/src/components/elements/Expander/Expander.tsx — prop rename only
  • frontend/lib/src/components/elements/Expander/useDetailsAnimation.ts — core refactor
  • frontend/lib/src/components/elements/Expander/useDetailsAnimation.test.ts — updated + new tests

Code Quality

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

Strengths:

  • Clear separation of concerns: animateTo handles open/close transitions, animateResize handles content size changes, and handleToggle handles user interaction.
  • Reflow optimization is well-documented with explicit "read phase" / "write phase" comments and properly scoped eslint-disable blocks.
  • The isOpenRef pattern correctly avoids stale closures in callbacks and effects, enabling rapid-toggle support.
  • The ?? operator in useState(backendExpanded ?? false) is more precise than the old || operator for the boolean | null | undefined type.
  • useCallback dependencies are correct and minimal, ensuring referential stability.

Issues:

  1. Missing eslint-disable for setIsOpen in useEffect (useDetailsAnimation.ts, lines 230 and 257): The old code had // eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing with external proto state for its setIsOpen call inside the effect. The new code calls setIsOpen in two places within the backend sync effect (line 230 for label changes, line 257 for backend state sync) without the suppression comment. This rule is actively enforced across the codebase (9+ other files use the suppression). This will likely cause lint errors.

    Suggested fix — add suppression comments:

    // Line 230 (label change reset):
    // eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing with backend proto state on label change
    setIsOpen(newOpen)
    
    // Line 257 (backend sync):
    // eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing with external backend proto state
    setIsOpen(backendExpanded)
  2. Minor: duplicate timeout cleanup (useDetailsAnimation.ts, lines 309-313 and 319-324): resizeTimeoutRef is cleaned up in both the ResizeObserver effect cleanup and the separate unmount effect. This is harmless (defensive programming), but worth noting as it could be simplified by removing the resizeTimeoutRef cleanup from the unmount effect since the ResizeObserver effect's cleanup will always run on unmount.

Test Coverage

Test coverage is comprehensive and well-organized:

  • Initial state: 4 tests covering true, false, null, undefined values of backendExpanded.
  • handleToggle: 6 tests covering open/close toggle, onToggle callback (present and absent), preventDefault, and rapid double-toggle.
  • Backend sync: 5 tests covering state change, null preservation, undefined preservation, and label-triggered reset.
  • Cleanup: 1 test for safe unmount.
  • ResizeObserver: 7 tests covering observer lifecycle (observe/disconnect), closed-details guard, debouncing, close-mode guard, threshold filtering, above-threshold animation, and unmount cleanup.

The rapid double-toggle test is particularly valuable as it validates the isOpenRef pattern for handling multiple toggles within a single React batch.

Minor gap: There is no direct test for the animateTo path where contentHeight === 0 (the zero-content fallback that clears locked styles instead of animating). This is a minor edge case that would primarily matter for widget mode.

Backwards Compatibility

No breaking changes. The initialExpandedbackendExpanded rename is an internal API change. The hook is only consumed by Expander.tsx, which is updated in this PR. No public-facing Python API or protobuf changes.

Security & Risk

No security concerns. This is a pure frontend animation refactor with no changes to data handling, authentication, or external communication.

Regression risk is low:

  • The Safari workaround removal is the highest-risk change, but it's been manually verified on Safari per the PR description.
  • The ResizeObserver approach is a more robust solution than the timeout-based workaround.
  • Existing E2E tests for the expander (st_expander_test.py, st_expander_state_test.py) will catch any behavioral regressions.

Accessibility

No accessibility impact. The component continues to use semantic <details> and <summary> elements. The inert attribute for collapsed content, focus-visible styling, and keyboard interaction remain unchanged.

Recommendations

  1. Add eslint-disable comments for setIsOpen calls inside useEffect at lines 230 and 257 of useDetailsAnimation.ts. The react-hooks/set-state-in-effect rule is actively enforced, and these calls need suppression with justification (syncing with external backend state). This is the same pattern used in Dialog.tsx, NumberInput.tsx, and other components.

  2. Consider adding a test for the zero-content fallback path in animateTo — when contentHeight === 0, the function clears inline styles instead of animating. This will become more relevant when widget mode is introduced.

  3. Ensure onToggle callers wrap the callback in useCallback when this is used in widget mode, to avoid unnecessary recreation of handleToggle on every render.

Verdict

CHANGES REQUESTED: The code is well-designed and thoroughly tested, but the missing eslint-disable comments for setIsOpen calls inside useEffect (lines 230 and 257) will likely cause lint failures. This is a quick fix — add the suppression comments consistent with the pattern used across the rest of the codebase.


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

@sfc-gh-lwilby sfc-gh-lwilby force-pushed the lwilby/expander-animation-improvements branch from 91120e2 to 28ad5f0 Compare February 13, 2026 17:13
@sfc-gh-lwilby sfc-gh-lwilby added ai-review If applied to PR or issue will run AI review workflow change:feature PR contains new feature or enhancement implementation impact:internal PR changes only affect internal code security-assessment-completed labels Feb 13, 2026
@sfc-gh-lwilby sfc-gh-lwilby marked this pull request as ready for review February 13, 2026 17:15
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Feb 13, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR replaces the Safari two-step repaint workaround in the Expander animation with a ResizeObserver-based approach and introduces several improvements to the useDetailsAnimation hook:

  • ResizeObserver for content resize: Instead of the requestAnimationFrame + 100ms timeout Safari hack, the hook now measures content immediately after details.open = true and uses a debounced ResizeObserver to handle delayed content painting or async content loading.
  • Smooth interruption: Captures current computed height via getBoundingClientRect() before cancelling a running animation, so mid-animation toggles start from the actual visual position.
  • Nullable backendExpanded handling: Explicit branching for null/undefined (preserve current state), label changes (reset without animation), and initial mount (set DOM directly, skip animation).
  • onToggle callback: Accepts an optional callback for notifying callers on user toggle, preparing for widget mode.
  • Renamed prop: initialExpandedbackendExpanded to better reflect semantics.

Files changed: Expander.tsx (1 line), useDetailsAnimation.ts (rewritten), useDetailsAnimation.test.ts (expanded significantly).

Code Quality

The code is well-structured and follows React best practices:

  • Ref-based state tracking (isOpenRef) avoids stale closure issues in handleToggle, enabling correct rapid-toggle behavior — a notable improvement over the previous setIsOpen(prev => !prev) + isOpen dependency approach.
  • DOM read/write batching is carefully documented, with eslint-disable comments justifying each forced reflow. The BATCH READ PHASE / WRITE PHASE comments make the intent clear.
  • Effect usage is appropriate: The backend sync effect synchronizes with an external system (backend proto state). The ResizeObserver effect manages a browser subscription. Both have proper cleanup. This aligns with the "You Might Not Need an Effect" guidelines.
  • useCallback dependencies are minimal and correct: cancelAnimation has [] deps (stable), animateTo and animateResize depend only on cancelAnimation (stable), so the ResizeObserver effect [animateResize] won't re-subscribe unnecessarily.
  • Constants (RESIZE_DEBOUNCE_MS, RESIZE_THRESHOLD_PX) are extracted to module scope with clear JSDoc.

Minor issue:

  1. useDetailsAnimation.ts line 261 — setIsOpen(backendExpanded) inside the backend sync useEffect is missing an eslint-disable-next-line react-hooks/set-state-in-effect comment. Line 233 has the disable for the identical pattern (setIsOpen(newOpen) in the label-change branch of the same effect). This inconsistency may cause a lint warning/error depending on rule configuration:
      // 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 Coverage

Test coverage is comprehensive and well-organized:

  • Initial state (4 tests): true, false, null, undefined.
  • handleToggle (6 tests): open/close toggle, onToggle callback (present and absent), preventDefault, rapid double-toggle.
  • Backend sync (4 tests): state change, null ClearField, undefined preservation, label-based reset.
  • Cleanup (1 test): unmount safety.
  • ResizeObserver (7 tests): observe/disconnect lifecycle, closed-details guard, debouncing, close-mode guard (isOpenRef), threshold check (under and over), zero content height branch, unmount timeout cleanup.

The TestHarness component in the ResizeObserver section correctly wires real DOM elements to the hook's refs, enabling realistic integration-style tests. The use of vi.useFakeTimers() for debounce testing is appropriate.

The animateHeight.test.ts (from the base branch, unchanged) covers creation, cancel, and finish/cancel lifecycle.

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 Compatibility

No breaking changes for end users:

  • The initialExpandedbackendExpanded rename is an internal API change. The only consumer (Expander.tsx) is updated in the same PR.
  • The public component interface (ExpanderProps) is unchanged.
  • The new onToggle callback is optional and not yet wired in Expander.tsx, so behavior is identical to before.
  • The removal of the Safari timeout workaround is a behavioral improvement, not a regression. Content that previously animated in two steps (5px jerk + measure) now animates smoothly or defers to ResizeObserver.

Security & Risk

No security concerns. The changes are purely frontend animation logic with no network, storage, or user data implications.

Risk assessment:

  • Low risk: The ResizeObserver approach is well-tested and more robust than the previous setTimeout-based Safari workaround.
  • Edge case: If ResizeObserver fires during a close animation (user clicks close while content is resizing), the isOpenRef.current guard at line 289 correctly prevents interference. This is explicitly tested.

Accessibility

No accessibility regressions:

  • The semantic <details> / <summary> structure is preserved.
  • The inert attribute on collapsed content panels remains in place (line 141 of Expander.tsx), correctly excluding hidden content from focus order and find-in-page.
  • No changes to ARIA attributes, keyboard interaction, or focus management.

Recommendations

  1. Add missing eslint-disable comment: Add // eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing with external backend state before line 261 in useDetailsAnimation.ts (setIsOpen(backendExpanded)) for consistency with line 232.

Verdict

APPROVED: 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 opus-4.6-thinking.

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

github-actions bot commented Feb 13, 2026

📈 Frontend coverage change detected

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

  • Current PR: 87.0100% (13995 lines, 1817 missed)
  • Latest develop: 86.9600% (13938 lines, 1817 missed)

🎉 Great job on improving test coverage!

📊 View detailed coverage comparison

@sfc-gh-lwilby sfc-gh-lwilby force-pushed the lwilby/expander-animation-improvements branch from 28ad5f0 to b92c66a Compare February 13, 2026 17:41
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the lwilby/expander-refactor-extract-hook branch from 66563b6 to 8bbe616 Compare February 13, 2026 17:41
import { BORDER_SIZE } from "./styled-components"

/**
* Delay before measuring content height for the Safari repaint workaround (ms).
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 is now handled with a resizeObserver which also supports dynamic content arriving at a delay after rerun.

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

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/undefined backend expanded state and introduced an optional onToggle callback; 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
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
isOpenRef.current = backendExpanded
isOpenRef.current = backendExpanded
// eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing with external backend state (expanded)

Copilot uses AI. Check for mistakes.
Comment on lines +177 to +188
animationRef.current = animateHeight(
details,
currentHeight,
targetHeight,
{
onFinish: () => {
if (detailsRef.current) {
detailsRef.current.open = false
}
},
}
)
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

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).

Suggested change
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)

Copilot uses AI. Check for mistakes.
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 👍

@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR refactors expander animation logic into useDetailsAnimation, adds a debounced ResizeObserver path, introduces smoother interruption handling, and expands unit tests substantially for toggle/backend-sync/cleanup scenarios.

Code Quality

Overall structure is cleaner than before (hook responsibilities are clearer, cancellation logic is centralized, and cleanup paths are explicit).
One blocking behavior issue is present:

  • frontend/lib/src/components/elements/Expander/useDetailsAnimation.ts (around lines 297-307): inside the ResizeObserver callback, currentHeight is measured after layout has already applied the content size change, and targetHeight is computed from that same post-resize DOM (summaryHeight + contentHeight + borders). In normal open state, these are effectively equal, so Math.abs(currentHeight - targetHeight) > RESIZE_THRESHOLD_PX is usually false and no animation is started.
  • This means the newly introduced "animate content resize" behavior is not reliably achieved for async content growth once the expander is already open.

Test Coverage

Coverage is broad for this hook and includes good edge cases (rapid toggles, nullable backend values, observer lifecycle, timeout cleanup).
However, the new resize tests rely on mocked getBoundingClientRect values that can force impossible/atypical relationships between details current height and computed target height, so they do not catch the real-world issue above.

Backwards Compatibility

No API-breaking changes for end users are visible in this diff, and the Expander component integration remains straightforward.
Main risk is behavioral: async content size changes are expected to animate after this refactor, but current logic likely leaves them unanimated in common cases.

Security & Risk

No direct security concerns identified.
Primary regression risk is UX/behavioral (animation path not triggering when intended), especially in the Safari fallback scenario this PR targets.

Accessibility

No new a11y regressions observed in this diff. Interaction remains on semantic <summary>/<details> elements, and no problematic ARIA/focus changes were introduced.

Recommendations

  1. Rework resize animation to compare against a persisted previous measured height (or lock current rendered height before layout-driven expansion) rather than comparing two post-resize measurements from the same frame.
  2. Add at least one integration-style test that exercises realistic DOM sequencing for async content growth (open expander -> content height changes later) without forcing contradictory mocked geometry.
  3. Add a focused test for the Safari fallback path (contentHeight === 0 during open) that verifies subsequent content paint triggers an actual height animation.

Verdict

CHANGES REQUESTED: The refactor is strong overall, but the new ResizeObserver resize-animation path appears functionally incomplete in real DOM timing and should be fixed before merge.


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

@github-actions github-actions bot added the do-not-merge PR is blocked from merging label Feb 13, 2026
Comment on lines +219 to +241
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
}

Copy link
Copy Markdown
Collaborator

@lukasmasuch lukasmasuch Feb 13, 2026

Choose a reason for hiding this comment

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

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.

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.

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.

Base automatically changed from lwilby/expander-refactor-extract-hook to develop February 13, 2026 19:37
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the lwilby/expander-animation-improvements branch from b92c66a to 56859f6 Compare February 13, 2026 20:05
@sfc-gh-lwilby sfc-gh-lwilby force-pushed the lwilby/expander-animation-improvements branch from 56859f6 to 0c911d0 Compare February 13, 2026 21:22
@sfc-gh-lwilby sfc-gh-lwilby added the ai-review If applied to PR or issue will run AI review workflow label Feb 13, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Feb 13, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR refactors the useDetailsAnimation hook in the Expander component with three main improvements:

  1. ResizeObserver replaces Safari timeout workaround: The old two-step repaint hack (5px animation → 100ms delay → measure real height) is replaced by a ResizeObserver-based approach that detects when content is painted and animates accordingly.
  2. Smooth animation interruption: Mid-animation toggles now capture the current visual height via getBoundingClientRect() before cancelling, preventing visual jumps.
  3. Explicit nullable backendExpanded handling: Clear branching for null/undefined (preserve current state), label changes (reset without animation), and initial mount (set DOM directly, skip animation).

Additionally, an onToggle callback option is added to prepare for widget mode in Dynamic Expanders, and the prop is renamed from initialExpanded to backendExpanded to better reflect its semantics.

Code Quality

The code is well-structured and follows the project's conventions:

  • Ref-based stale closure avoidance (isOpenRef): Correctly solves the problem of handleToggle needing the latest open state without recreating the callback — essential for rapid double-toggle correctness.
  • Read/write phase batching: Layout reads are consistently batched before style writes to minimize forced reflows, with appropriate eslint-disable comments explaining each case.
  • Effect usage is justified: The three effects each synchronize with an external system (backend state + imperative DOM, ResizeObserver subscription, and unmount cleanup), satisfying the "You Might Not Need an Effect" guidelines.
  • Cleanup is thorough: All effects clean up their subscriptions, timeouts, and animations.
  • useCallback stability: cancelAnimation, animateTo, and animateResize have stable dependency arrays (only depending on other stable callbacks or refs), preventing unnecessary effect re-runs.

Minor observations:

  • useDetailsAnimation.ts line 261: setIsOpen(backendExpanded) inside the backend sync effect lacks the react-hooks/set-state-in-effect ESLint suppression that the label-changed branch at line 233 has. If the rule fires for both calls, this might cause a lint warning. Verify the linter is clean here.

  • The animateTo function (line 160-167) intentionally leaves inline styles locked when contentHeight === 0 for the ResizeObserver to handle later. If content genuinely has zero height and never changes (unlikely but possible), the overflow: hidden and fixed height would remain. This is an acceptable edge case given the documented rationale, but worth keeping in mind.

  • The ResizeObserver effect at line 267 depends on [animateResize]. Since animateResize is referentially stable (depends only on cancelAnimation which is also stable), the observer is set up once and never recreated. This is correct but could be made more explicit with a comment.

Test Coverage

Test coverage is thorough and well-designed:

  • Initial state tests: All four cases (true, false, null, undefined) are covered.
  • Toggle behavior: Includes basic toggle, onToggle callback, missing callback safety, preventDefault, and rapid double-toggle (an important edge case for the ref-based approach).
  • Backend sync: Tests for state change, null preservation (ClearField), undefined preservation, and label-change reset.
  • ResizeObserver suite: Uses a real DOM harness (TestHarness component with createElement) — 8 tests covering observe/disconnect lifecycle, closed-details guard, debouncing, close-mode guard, threshold check, zero-content locking, delayed content animation, and unmount timeout cleanup.

The test quality follows the project conventions:

  • Uses vi.fn() (Vitest, not Jest).
  • Uses vi.useFakeTimers() / vi.advanceTimersByTime() for debounce testing.
  • The mockElementHeight helper cleanly mocks getBoundingClientRect.
  • Proper cleanup in afterEach (real timers, restore mocks, original ResizeObserver).

One minor note: the animateHeight.test.ts file (unchanged, pre-existing) covers the animateHeight utility separately, which is good for the layered testing approach.

Backwards Compatibility

  • Internal-only API change: initialExpandedbackendExpanded rename is confined to the hook's UseDetailsAnimationOptions interface, which is only used internally by Expander.tsx. No public API is affected.
  • Behavioral change: The Safari repaint workaround is removed in favor of ResizeObserver. Per the PR description, this was manually verified in Safari. The ResizeObserver API has excellent browser support (96%+).
  • No proto or backend changes: The element.expanded field is used identically in Expander.tsx.
  • Existing E2E tests: The st_expander_test.py and st_expander_state_test.py E2E tests are unchanged and should continue to pass, providing regression coverage.

No breaking changes for end users.

Security & Risk

No security concerns identified. The changes are purely frontend animation logic with no user data handling, network requests, or DOM injection vectors.

Risk assessment:

  • Low risk: The ResizeObserver-based approach is a standard web API pattern. The debouncing and threshold guards prevent performance issues from rapid resize events.
  • Safari regression risk: The old Safari workaround is fully removed. If there are edge cases not covered by manual testing (e.g., very slow Safari paint on older devices), the contentHeight === 0 fallback path + ResizeObserver should handle it gracefully. This is a reasonable trade-off.

Accessibility

No accessibility changes in this PR. The Expander component's keyboard navigation, focus management, inert attribute behavior, and ARIA semantics are unchanged. The Expander.test.tsx tests for inert attribute toggling remain in place.

Recommendations

  1. Verify lint cleanliness: Confirm that setIsOpen(backendExpanded) at line 261 of useDetailsAnimation.ts doesn't trigger react-hooks/set-state-in-effect. If it does, add a suppression comment similar to line 232-233.

  2. Consider a comment on ResizeObserver effect stability: At line 319 (}, [animateResize])), a brief comment like // animateResize is referentially stable — observer is created once would help future readers understand why the observer isn't recreated.

  3. (Very minor) The resizeTimeoutRef cleanup exists in both the ResizeObserver effect cleanup (line 315) and the unmount cleanup effect (line 325). This is defensive and harmless, but a brief comment on the unmount effect noting it's a safety net for edge cases could improve clarity.

Verdict

APPROVED: Well-crafted refactoring that replaces a browser-specific hack with a standard ResizeObserver approach, improves animation interruption handling, and adds comprehensive test coverage. The code follows project conventions, effects are properly justified and cleaned up, and the changes are backwards compatible.


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

@github-actions github-actions bot removed the do-not-merge PR is blocked from merging label Feb 13, 2026
@sfc-gh-lwilby
Copy link
Copy Markdown
Collaborator Author

@lukasmasuch I updated this since you approved the PR because of this AI review finding:

frontend/lib/src/components/elements/Expander/useDetailsAnimation.ts (around lines 297-307): inside the ResizeObserver callback, currentHeight is measured after layout has already applied the content size change, and targetHeight is computed from that same post-resize DOM (summaryHeight + contentHeight + borders). In normal open state, these are effectively equal, so Math.abs(currentHeight - targetHeight) > RESIZE_THRESHOLD_PX is usually false and no animation is started.
This means the newly introduced "animate content resize" behavior is not reliably achieved for async content growth once the expander is already open.

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

@sfc-gh-lwilby sfc-gh-lwilby merged commit 6f993c6 into develop Feb 14, 2026
54 of 56 checks passed
@sfc-gh-lwilby sfc-gh-lwilby deleted the lwilby/expander-animation-improvements branch February 14, 2026 11:09
lukasmasuch pushed a commit that referenced this pull request Feb 20, 2026
…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
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:internal PR changes only affect internal code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants