Skip to content
This repository was archived by the owner on Aug 5, 2026. It is now read-only.

fix(BpkBottomSheet): clear close-animation timer on unmount - #4709

Open
tuxiu.luo (LuoTuxiu) wants to merge 2 commits into
mainfrom
fix/bottom-sheet-timer-leak
Open

fix(BpkBottomSheet): clear close-animation timer on unmount#4709
tuxiu.luo (LuoTuxiu) wants to merge 2 commits into
mainfrom
fix/bottom-sheet-timer-leak

Conversation

@LuoTuxiu

Copy link
Copy Markdown
Contributor

Summary

  • Store the 240ms close-animation setTimeout ID in a ref
  • Add a useEffect cleanup that calls clearTimeout on unmount
  • Add a regression test that unmounts before the timer fires and asserts onClose is not called

Why

BpkBottomSheet.handleClose schedules a 240ms setTimeout to wait for the slide-out animation before calling the consumer's onClose. This timer was never cancelled on unmount.

When the host component unmounted before the timer fired (e.g. a test finishing before the animation completes), the timer would still fire and call onClose, which triggered setState on an already-unmounted component. In JSDOM test environments this surfaces as:

ReferenceError: window is not defined
 ❯ getCurrentEventPriority  react-dom.development.js:10993
 ❯ dispatchSetState         react-dom.development.js:16648
 ❯ onClose (consumer code)
 ❯ Timeout._onTimeout       BpkBottomSheet.tsx

The error originates in the consumer's test but is attributed to unrelated test files, making it very hard to diagnose.

Consumers have been working around this by guarding their onClose callback with an isMounted ref (e.g. hotels-website#11795). The root fix belongs here in Backpack.

Test plan

  • New regression test: unmount before 240ms timer fires → onClose not called
  • Existing BpkBottomSheet tests pass
  • CI green

The 240ms close-animation setTimeout was never cancelled when the
component unmounted. If the host component unmounted before the timer
fired (e.g. during test teardown), the timer would still call onClose,
triggering a setState on an already-unmounted consumer and causing
a ReferenceError: window is not defined in JSDOM environments.

Store the timer ID in a ref and clear it in a useEffect cleanup so the
callback is always cancelled on unmount.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 16, 2026 10:29
@LuoTuxiu tuxiu.luo (LuoTuxiu) added the patch Patch production bug label Jun 16, 2026
@skyscanner-backpack-bot

Copy link
Copy Markdown
Contributor

Visit https://backpack.github.io/storybook-prs/4709 to see this build running in a browser.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR addresses a timer leak in BpkBottomSheet by tracking the close-animation timeout and clearing it on unmount to prevent onClose firing after the component has been removed.

Changes:

  • Store the close setTimeout handle in a ref and clear it in a useEffect unmount cleanup.
  • Add a regression test intended to verify onClose is not called after unmount.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
packages/backpack-web/src/bpk-component-bottom-sheet/src/BpkBottomSheet.tsx Track/clear close-animation timer on unmount to prevent post-unmount callbacks.
packages/backpack-web/src/bpk-component-bottom-sheet/src/BpkBottomSheet-test.tsx Adds a regression test targeting the timer leak scenario.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 146 to 163
@@ -147,7 +157,7 @@ const BpkBottomSheet = ({
const timeoutDuration = isAboveMobile ? 0 : animationTimeout;

setExiting(true);
setTimeout(() => {
closeTimerRef.current = setTimeout(() => {
onClose(arg0, arg1);
setExiting(false);
}, timeoutDuration);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — now clears any in-flight timer before scheduling a new one, and resets closeTimerRef.current = null after the timer fires so state accurately reflects whether a timer is pending.

Comment on lines +243 to +263
it('does not call onClose after unmount (timer leak regression)', () => {
jest.useFakeTimers();
const onClose = jest.fn();
const { unmount } = render(
<BpkBottomSheet
ariaLabelledby="bottom-sheet"
closeLabel="Close"
id="my-bottom-sheet"
isOpen
onClose={onClose}
>
Content
</BpkBottomSheet>,
);
// Simulate close button click would normally schedule a 240ms timer.
// Unmounting before the timer fires should cancel it.
unmount();
jest.advanceTimersByTime(300);
expect(onClose).not.toHaveBeenCalled();
jest.useRealTimers();
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — the test now clicks the Close button first (via userEvent) so the 240ms timer is actually scheduled before unmounting. The assertion now meaningfully catches the regression.

Comment on lines +243 to +263
it('does not call onClose after unmount (timer leak regression)', () => {
jest.useFakeTimers();
const onClose = jest.fn();
const { unmount } = render(
<BpkBottomSheet
ariaLabelledby="bottom-sheet"
closeLabel="Close"
id="my-bottom-sheet"
isOpen
onClose={onClose}
>
Content
</BpkBottomSheet>,
);
// Simulate close button click would normally schedule a 240ms timer.
// Unmounting before the timer fires should cancel it.
unmount();
jest.advanceTimersByTime(300);
expect(onClose).not.toHaveBeenCalled();
jest.useRealTimers();
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — moved timer restoration to afterEach(() => jest.useRealTimers()) so it runs even if the test throws, and wrapped advanceTimersByTime in act() to avoid React update warnings.

@skyscanner-backpack-bot

Copy link
Copy Markdown
Contributor
Warnings
⚠️

Package source files (e.g. packages/package-name/src/Component.js) were updated, but snapshots weren't. Have you checked that the tests still pass?

Browser support

If this is a visual change, make sure you've tested it in multiple browsers.

Generated by 🚫 dangerJS against 623c153

- Clear any in-flight timer before scheduling a new one (multiple
  handleClose calls no longer leave orphaned timers)
- Reset closeTimerRef.current to null after the timer fires
- Fix regression test: click Close first so the timer is actually
  scheduled before unmounting
- Use afterEach to restore real timers (guards against test-throw leaks)
- Wrap advanceTimersByTime in act() to avoid React update warnings

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@skyscanner-backpack-bot

Copy link
Copy Markdown
Contributor

Visit https://backpack.github.io/storybook-prs/4709 to see this build running in a browser.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

patch Patch production bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants