`ExpandableCalendar` knob: rapid taps leave calendar in a desynced state
Summary
Tapping the ExpandableCalendar knob in quick succession (or in some cases even a single tap while the spring animation is still settling) leaves the calendar visually desynced from its internal position state. The typical symptom is the
week-calendar overlay snapping into view before the month grid has finished animating its height, creating the impression that "the current week jumps to the top" on collapse, and subsequent taps are no-ops until state resyncs.
Environment
react-native-calendars: 1.1313.0 (also reproduced against 1.1314.0 —src/is identical)- React Native: 0.79.6
- Expo: 53 (managed workflow)
- Platform: iOS + Android
Reproduction
- Mount
<ExpandableCalendar />inside a<CalendarProvider />in the closed (week) position. - Tap the knob to expand — works correctly.
- Tap the knob rapidly to collapse/expand repeatedly.
Expected: each tap toggles position cleanly, or additional taps during animation are ignored.
Actual: after 2–3 rapid taps the calendar enters a state where the knob no longer toggles as expected — taps may do nothing, or the week overlay flashes in without the month grid collapsing.
Minimal props used
<CalendarProvider date={dayjs().format('YYYY-MM-DD')} showTodayButton={false}>
<ExpandableCalendar
markedDates={markedDates}
markingType="multi-dot"
onDayPress={(day) => onDateChange?.(day.dateString)}
closeOnDayPress
disablePan={false}
hideArrows={false}
initialPosition={Positions.CLOSED}
firstDay={1}
allowShadow={false}
theme={theme}
/>
</CalendarProvider>Root cause (from tracing the library source)
In src/expandableCalendar/index.js, bounceToPosition has two ordering issues:
const bounceToPosition = (toValue = 0) => {
// ...
_height.current = toValue || newValue; // (1) set synchronously
_isOpen = _height.current >= threshold;
resetWeekCalendarOpacity(_isOpen); // (2) flips week overlay opacity immediately
Animated.spring(deltaY, { /* ... */ }).start(() => {
onCalendarToggled?.(_isOpen); // (3) fires before setPosition
setPosition(() => _height.current === closedHeight ? Positions.CLOSED : Positions.OPEN);
});
};And:
const toggleCalendarPosition = useCallback(() => {
bounceToPosition(isOpen ? closedHeight : openHeight.current);
return !isOpen;
}, [isOpen, bounceToPosition, closedHeight]);Two problems compound:
- Opacity flips before the spring runs.
resetWeekCalendarOpacity(_isOpen)is called synchronously, so when collapsing, the hidden week overlay (position: absolute; top: headerHeight) becomesopacity: 1immediately while the month grid's height is still animating down. The visual is the week row appearing at the top before the month has finished collapsing. toggleCalendarPositioncloses over a staleisOpen.setPositionis only called in the spring'sstart()completion callback, soisOpendoesn't update until after the animation finishes.onCalendarToggledfires beforesetPosition, meaning any consumer using it as a "safe to tap again" signal will tap before React has flushed the state update, andtoggleCalendarPosition's memoized closure will still hold the pre-animationisOpen— producing a no-op or wrong-direction toggle.
Observed log trace
Instrumenting the library (single, well-spaced tap to collapse):
[RNC] toggleCalendarPosition isOpen=true _height=368.67 closedHeight=184.67 openHeight=368.67
[RNC] bounceToPosition toValue=184.67 newValue=368.67 _height->184.67 _isOpen=false
[RNC] spring.done _isOpen=false _height=184.67 Single taps behave correctly. Under rapid taps, we observed toggleCalendarPosition being invoked with isOpen=true on a tap that occurred after a prior collapse had already completed — confirming the stale closure.
What we tried on the consumer side
- Replaced
onDateChangedonCalendarProviderwithonDayPressonExpandableCalendar— fixed an unrelated feedback loop we had, but the knob race is independent. hideKnob={true}+ customPressableknob callingtoggleCalendarPositionviauseImperativeHandleref, with anisTogglingRefflag cleared inonCalendarToggled— still desynced, becauseonCalendarToggledfires one tick beforesetPositionlands.- Deferred the flag clear via
setTimeout(..., 0)— handles the rapid-tap case, but the custom knob loses the built-in pan gesture on the knob itself (pan handlers are on theAnimated.Viewwrapper, not reachable from a consumer-rendered knob), so swipe-on-knob no longer collapses. - Custom
PanResponderon the replacement knob to re-implement swipe-to-toggle — works mechanically, but doesn't match the feel of the library's pan handling and we'd prefer not to diverge.
None of these are satisfying because the race and the ordering both live inside bounceToPosition, and there's no injection point on the built-in knob (TouchableOpacity with onPress={toggleCalendarPosition} — no onKnobPress prop, no event
wrapping, no "is animating" signal exposed to consumers).
Suggested fixes
Either (or ideally both):
- Move
resetWeekCalendarOpacityinto the spring'sstart()callback (or animate opacity alongside height) so the week overlay doesn't appear until the height animation has completed. - Guard
toggleCalendarPositionagainst re-entry while a spring is in flight. Track anisAnimatingref, set ittrueat the start ofbounceToPosition, clear it in the spring completion callback, and early-return fromtoggleCalendarPosition(and ideally the knob'sonPress) when it's set. - Swap the order in the spring callback so
setPositionruns beforeonCalendarToggled, so consumers using the callback as a "safe to re-tap" signal aren't racing the state update.
Happy to contribute a PR if the maintainers are open to it.
Source: wix/react-native-calendars