Skip to content

[refactor] extract Expander animation logic into useDetailsAnimation hook#13933

Merged
sfc-gh-lwilby merged 1 commit intodevelopfrom
lwilby/expander-refactor-extract-hook
Feb 13, 2026
Merged

[refactor] extract Expander animation logic into useDetailsAnimation hook#13933
sfc-gh-lwilby merged 1 commit intodevelopfrom
lwilby/expander-refactor-extract-hook

Conversation

@sfc-gh-lwilby
Copy link
Copy Markdown
Collaborator

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

Describe your changes

Extracts the <details> open/close animation logic from Expander.tsx into a reusable useDetailsAnimation hook and a standalone animateHeight utility.

  • useDetailsAnimation manages open state, backend sync, and the toggle animation (including the Safari two-step repaint workaround)
  • animateHeight wraps the Web Animations API with a cancellable AnimationHandle that distinguishes finish vs cancel cleanup semantics

Testing Plan

  • Unit Tests (JS)
    • useDetailsAnimation.test.ts — initial state, toggle behavior, backend sync, label-based reset, cleanup
    • animateHeight.test.ts — animation creation, custom options, finish/cancel lifecycle, promise resolution

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

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

@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 12, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Feb 12, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR refactors the Expander component by extracting its animation logic into two new modules:

  1. animateHeight.ts — A pure utility that wraps the Web Animations API for height transitions, returning a cancelable AnimationHandle.
  2. useDetailsAnimation.ts — A custom React hook encapsulating the <details> open/close state, refs, backend proto sync, and the Safari two-step repaint workaround.

The Expander.tsx component is simplified from ~150 lines of logic to ~50, consuming the hook and focusing purely on rendering. No behavioral changes are intended.

Code Quality

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

  • Named constants: Magic numbers (100 ms delay, 5 px height) are replaced with descriptive module-level constants (SAFARI_REPAINT_DELAY_MS, SAFARI_REPAINT_HEIGHT), improving readability.
  • JSDoc comments: Both new modules have thorough documentation, including the AnimationHandle interface, hook options, and the Safari workaround rationale.
  • Referential stability: cancelAnimation, startAnimation, and handleToggle are wrapped in useCallback with correct dependency arrays. The handleToggle dependency on isOpen is necessary since the animation branching logic reads the current state.
  • Cleanup: The unmount effect properly cancels running animations and pending timeouts. The animateHeight utility correctly differentiates between "finish" (clears styles) and "cancel" (preserves styles for the caller).
  • RORO pattern: The hook follows the Receive-an-Object, Return-an-Object pattern per AGENTS.md.
  • Naming: Refs use the Ref suffix (detailsRef, summaryRef, etc.) and handlers use the handle prefix (handleToggle), both enforced by project conventions.
  • Effect usage: The useEffect for syncing backend proto state is justified — it synchronizes with an external system (the protobuf state from the server). The eslint-disable comment is updated from a vague TODO to a clear Syncing with external proto state.

Minor observation (not a blocker): The startAnimation function in useDetailsAnimation.ts always calls cancelAnimation() which clears both animation and timeout refs independently. The original code only cleared the timeout when there was an animation to cancel. This is actually a slight improvement — it's more robust against edge cases where a timeout exists without a corresponding animation.

Behavioral equivalence note: I verified the onAnimationFinish behavior. In the original code, the finish handler set detailsEl.open for both open and close directions. In the refactored code, onFinish is only provided for the closing path. This is correct because during opening, detailsEl.open = true is already set at the start of the toggle handler, making the finish handler's open = true a no-op. The style clearing order differs slightly (new code clears styles before calling onFinish), but since both operations execute synchronously in the same event handler callback, no visual difference is possible.

Test Coverage

New unit tests are comprehensive:

  • animateHeight.test.ts (175 lines): Tests animation creation with default/custom params, cancel behavior, finish behavior (style clearing, onFinish callback, promise resolution), and the cancel-does-not-clear-styles contract.
  • useDetailsAnimation.test.ts (221 lines): Tests initial state for all input types (true, false, null, undefined), toggle behavior, preventDefault calling, backend sync (prop changes, null/ClearField preservation, label-change reset), and unmount cleanup.

Existing tests are unaffected:

  • Expander.test.tsx continues to cover the integrated component behavior (rendering, expanding/collapsing, icon display, inert attribute toggling).
  • E2E tests (st_expander_test.py, st_expander_state_test.py) exist and cover end-to-end behavior.
  • The vitest.setup.ts change adds cancel: vi.fn() to the global Element.prototype.animate mock, which the new animateHeight utility needs.

One area for potential improvement (non-blocking): The useDetailsAnimation hook tests don't exercise the animation branching logic within handleToggle (opening vs. closing paths, the Safari workaround). This is understandable since renderHook doesn't create real DOM elements with dimensions (getBoundingClientRect returns zeros), making the refs null and the animation code unreachable. The integration-level Expander tests and E2E tests provide this coverage.

Backwards Compatibility

No breaking changes. This is a purely internal refactoring:

  • No public API changes (Python or TypeScript exports).
  • No protobuf changes.
  • The Expander component's props, rendered output, test IDs, and CSS class names are all unchanged.
  • The ExpanderIcon component is unmodified.
  • The BORDER_SIZE constant remains exported from styled-components.ts and is now also consumed by the hook (was previously consumed by the component directly).

Security & Risk

No security concerns. This is a frontend-only refactoring with no changes to data handling, network communication, or authentication. The risk of regression is low due to:

  • The animation logic is a faithful extraction with no intentional behavioral changes.
  • Existing unit tests and E2E tests pass.
  • The refactoring introduces better cleanup (unmount effect) that the original code lacked.

Accessibility

No accessibility impact. The refactoring preserves all existing accessibility features:

  • The <details>/<summary> semantic HTML structure is unchanged.
  • The inert attribute behavior on collapsed content is preserved.
  • Keyboard interaction (via the native <summary> element) is unaffected.
  • The onClick handler on <summary> (a natively interactive element) remains appropriate.

Recommendations

  1. Consider adding a requestAnimationFrame cleanup (optional, non-blocking): Neither the old nor the new code cancels the requestAnimationFrame callback on unmount. If the component unmounts during the opening animation's rAF frame, the callback will execute against a detached DOM element. This is harmless but technically wasteful. A useRef for the rAF ID with cleanup in the unmount effect would be a minor improvement. However, since this is pre-existing behavior and the edge case is extremely narrow, this is not a required change for this PR.

  2. Extract statusIconTestIds to module scope (optional, non-blocking): In Expander.tsx line 51, the statusIconTestIds record is recreated on every render of ExpanderIcon. Per the project's "Static Data Structures" guidelines, this should be at module level. This is pre-existing and not introduced by this PR, but could be addressed as a follow-up.

Verdict

APPROVED: Clean, well-tested refactoring that extracts Expander animation logic into a reusable hook and utility with no behavioral changes, improved documentation, and proper cleanup.


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

@sfc-gh-lwilby sfc-gh-lwilby force-pushed the lwilby/expander-refactor-extract-hook branch from 87f055d to 95741ad Compare February 13, 2026 14:07
}

const contentHeight =
// eslint-disable-next-line streamlit-custom/no-force-reflow-access -- Need content height to calculate animation end value
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 follows the original implementation.

@sfc-gh-lwilby sfc-gh-lwilby force-pushed the lwilby/expander-refactor-extract-hook branch from 95741ad to 13ec12f Compare February 13, 2026 14:12
@sfc-gh-lwilby
Copy link
Copy Markdown
Collaborator Author

Consider adding a requestAnimationFrame cleanup (optional, non-blocking): Neither the old nor the new code cancels the requestAnimationFrame callback on unmount. If the component unmounts during the opening animation's rAF frame, the callback will execute against a detached DOM element. This is harmless but technically wasteful. A useRef for the rAF ID with cleanup in the unmount effect would be a minor improvement. However, since this is pre-existing behavior and the edge case is extremely narrow, this is not a required change for this PR.

This seems better to address in the next PR where the behaviour is changed.

@sfc-gh-lwilby sfc-gh-lwilby marked this pull request as ready for review February 13, 2026 14:16
Copilot AI review requested due to automatic review settings February 13, 2026 14:16
@sfc-gh-lwilby sfc-gh-lwilby changed the title refactor: extract Expander animation logic into useDetailsAnimation hook [refactor] extract Expander animation logic into useDetailsAnimation hook Feb 13, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR refactors the Expander component by extracting the <details> element animation logic into a reusable custom hook (useDetailsAnimation) and a standalone animation utility (animateHeight). This improves code organization, testability, and reusability while preserving the original functionality including the Safari repaint workaround.

Changes:

  • Extracted animation state management and toggle logic into useDetailsAnimation hook
  • Created animateHeight utility wrapping Web Animations API with proper lifecycle handling
  • Added comprehensive unit tests for both the hook and utility
  • Updated vitest mock to include cancel method for animation objects

Reviewed changes

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

Show a summary per file
File Description
frontend/lib/src/components/elements/Expander/useDetailsAnimation.ts New hook managing open state, backend sync, animation refs, and toggle handler with Safari workaround
frontend/lib/src/components/elements/Expander/useDetailsAnimation.test.ts Comprehensive tests for hook behavior including state transitions, backend sync, and cleanup
frontend/lib/src/components/elements/Expander/animateHeight.ts Reusable animation utility with cancel support and distinct finish/cancel cleanup semantics
frontend/lib/src/components/elements/Expander/animateHeight.test.ts Tests for animation creation, lifecycle, and promise resolution
frontend/lib/src/components/elements/Expander/Expander.tsx Refactored to use new hook, removing ~120 lines of animation logic
frontend/vitest.setup.ts Added cancel method to animation mock for new utility

@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.0700%

  • Current PR: 86.9600% (13936 lines, 1817 missed)
  • Latest develop: 86.8900% (13920 lines, 1824 missed)

🎉 Great job on improving test coverage!

📊 View detailed coverage comparison

@sfc-gh-lwilby sfc-gh-lwilby force-pushed the lwilby/expander-refactor-extract-hook branch from 13ec12f to 66563b6 Compare February 13, 2026 15:50
@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
@sfc-gh-lwilby sfc-gh-lwilby merged commit 67ac679 into develop Feb 13, 2026
42 of 44 checks passed
@sfc-gh-lwilby sfc-gh-lwilby deleted the lwilby/expander-refactor-extract-hook branch February 13, 2026 19:37
lukasmasuch pushed a commit that referenced this pull request Feb 20, 2026
…hook (#13933)

## Describe your changes

Extracts the `<details>` open/close animation logic from `Expander.tsx`
into a reusable `useDetailsAnimation` hook and a standalone
`animateHeight` utility.

- `useDetailsAnimation` manages open state, backend sync, and the toggle
animation (including the Safari two-step repaint workaround)
- `animateHeight` wraps the Web Animations API with a cancellable
`AnimationHandle` that distinguishes finish vs cancel cleanup semantics

## Testing Plan

- [x] Unit Tests (JS)
- `useDetailsAnimation.test.ts` — initial state, toggle behavior,
backend sync, label-based reset, cleanup
- `animateHeight.test.ts` — animation creation, custom options,
finish/cancel lifecycle, promise resolution
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