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) becomes opacity: 1 immediately 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.
toggleCalendarPosition closes over a stale isOpen. setPosition is only called in the spring's start() completion callback, so isOpen doesn't update until after the animation finishes. onCalendarToggled fires before setPosition,
meaning any consumer using it as a "safe to tap again" signal will tap before React has flushed the state update, and toggleCalendarPosition's memoized closure will still hold the pre-animation isOpen — 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
onDateChanged on CalendarProvider with onDayPress on ExpandableCalendar — fixed an unrelated feedback loop we had, but the knob race is independent.
hideKnob={true} + custom Pressable knob calling toggleCalendarPosition via useImperativeHandle ref, with an isTogglingRef flag cleared in onCalendarToggled — still desynced, because onCalendarToggled fires one tick before
setPosition lands.
- 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 the Animated.View wrapper, not reachable from a consumer-rendered
knob), so swipe-on-knob no longer collapses.
- Custom
PanResponder on 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
resetWeekCalendarOpacity into the spring's start() callback (or animate opacity alongside height) so the week overlay doesn't appear until the height animation has completed.
- Guard
toggleCalendarPosition against re-entry while a spring is in flight. Track an isAnimating ref, set it true at the start of bounceToPosition, clear it in the spring completion callback, and early-return from
toggleCalendarPosition (and ideally the knob's onPress) when it's set.
- Swap the order in the spring callback so
setPosition runs before onCalendarToggled, 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.
Summary
Tapping the
ExpandableCalendarknob 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 internalpositionstate. The typical symptom is theweek-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)Reproduction
<ExpandableCalendar />inside a<CalendarProvider />in the closed (week) position.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
Root cause (from tracing the library source)
In
src/expandableCalendar/index.js,bounceToPositionhas two ordering issues:And:
Two problems compound:
resetWeekCalendarOpacity(_isOpen)is called synchronously, so when collapsing, the hidden week overlay (position: absolute; top: headerHeight) becomesopacity: 1immediately while the month grid'sheight 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, and
toggleCalendarPosition's memoized closure will still hold the pre-animationisOpen— producing a no-op or wrong-directiontoggle.
Observed log trace
Instrumenting the library (single, well-spaced tap to collapse):
Single taps behave correctly. Under rapid taps, we observed
toggleCalendarPositionbeing invoked withisOpen=trueon a tap that occurred after a prior collapse had already completed — confirming the stale closure.What we tried on the consumer side
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.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-renderedknob), so swipe-on-knob no longer collapses.
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 (TouchableOpacitywithonPress={toggleCalendarPosition}— noonKnobPressprop, no eventwrapping, no "is animating" signal exposed to consumers).
Suggested fixes
Either (or ideally both):
resetWeekCalendarOpacityinto the spring'sstart()callback (or animate opacity alongside height) so the week overlay doesn't appear until the height animation has completed.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.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.