Skip to content

ExpandableCalendar knob: rapid taps leave calendar in a desynced state #2778

@JordanZins

Description

@JordanZins

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

  1. Mount <ExpandableCalendar /> inside a <CalendarProvider /> in the closed (week) position.
  2. Tap the knob to expand — works correctly.
  3. 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:

  1. 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.
  2. 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

  1. Replaced onDateChanged on CalendarProvider with onDayPress on ExpandableCalendar — fixed an unrelated feedback loop we had, but the knob race is independent.
  2. 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.
  3. 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.
  4. 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):

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions