Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dragging events during ongoing backend requests results in duplicate events #7067

Closed
mparkki opened this issue Nov 24, 2020 · 19 comments
Closed
Assignees
Milestone

Comments

@mparkki
Copy link

mparkki commented Nov 24, 2020

Reduced Test Case

https://codesandbox.io/s/test-react-redux-typescript-forked-q05tx?file=/src/calendar.tsx

Bug Description

We're currently banging our heads against the wall with an issue with duplicate events whenever a user has initiated a drag and drop while an update to the redux state occurs. We've looked through examples/documentation on how to make FullCalendar work together with React/Redux (including https://github.com/fullcalendar/fullcalendar-example-projects/tree/master/react-redux by @arshaw ), but are still unable to figure out how we should fix the issue on our end.

Steps to reproduce:

  1. Drag and drop the event in the calendar to another time/day
  2. Immediately after dropping the event at another time/day, click and move the event to another time/day without releasing it
  3. Wait for a few seconds, and then drop the event

Expected behaviour:

  • Only a single instance of the event is present in the calendar

Actual behaviour:

  • Two events are present in the calendar. In other words, the event has been duplicated.

Is this an issue with FullCalendar itself? Or are we using FullCalendar in the wrong way?

Help is greatly appreciated.

@acerix
Copy link
Member

acerix commented Nov 30, 2020

I'm not very familiar with Redux but this does look like a bug to me. Moving to React repo since it's specific to that.

@acerix acerix transferred this issue from fullcalendar/fullcalendar Nov 30, 2020
@acerix acerix changed the title React/Redux: Dragging events during ongoing backend requests results in duplicate events Dragging events during ongoing backend requests results in duplicate events Nov 30, 2020
@NortonIce
Copy link

NortonIce commented Jan 21, 2021

I have the same issue but in my case every movement of an event duplicates it. Even without dragging it second time.

@nabati
Copy link

nabati commented Mar 30, 2021

Seeing this, or similar behaviour with duplicated events, but in our case we are just using simple component state and not redux.

@nabati
Copy link

nabati commented Mar 30, 2021

Simplified example. Thanks @mparkki for the starting point.

Repeatedly click and move the events in the calendar. You should get many duplicates. The fullcalendar component comes out of sync with the state provided by the react component.

Please advise @acerix

recording (20)

@nabati
Copy link

nabati commented Apr 6, 2021

Any timeplan for this @acerix ? It's literally preventing us from purchasing a license at this point.

@Gatix
Copy link

Gatix commented Apr 8, 2021

We're blocked by the same bug because we update the sources from the backend upon window focus. So when a user coming from a different window immediately drags an event it's duplicated every time.

@scmunro
Copy link

scmunro commented Apr 19, 2021

I'm having the same issue. I've found an acceptable workaround but would love to see the core issue resolved.

For others having the same issue my solution was to create a secondary store of event data that drives the calendar and only allow that to be updated when you aren't actively dragging/resizing an event. You can use eventDragStart and eventResizeStart to set a state variable called updatePending to true and then set it to false with on eventDrop/Resize.

The calendar reads the secondary store of event data which is automatically updated anytime data from the backend updates the primary store except when updatePending is true in which case the update is bypassed. There's always another update in the queue so you'll eventually update the second store.

Slows the UI a bit and is overkill if you don't need to duplicate your event stores but I was doing it anyway as part of how I handle the event data in the rest of the application.

@arshaw
Copy link
Member

arshaw commented May 26, 2021

@nabati , I assume this is your codepen? I can't seem to recreate the duplication.

I can recreate the original problem however.

The problem is occurring because the calendar is receiving what it considers to be a brand new event store, during a drag. By the time the dragging event is dropped, the calendar has lost reference to the original event, so the event being dragged is essentially considered an external event that is dropped onto the calendar, creating a new event.

The bugfix here is to make the calendar smarter about knowing that events with the same id, even if fetched at different times, are the same event. This is something I want to do when fullcalendar's internal data store is refactored in v6.

However, what many other apps do while waiting for a network write-request to complete is to block user editing. I would suggest this as a workaround. You could have a loading flag in your app's state, and set the FullCalendar component's editable flag to be !loading. Furthermore, you could attach a className to a parent element when the calendar is loading, and target the event elements to opacity:0.5 to give a nice visual indication that editing is temporarily not possible. This is very similar to what Google Calendar does.

I hope this helps as a workaround, although I know it is not ideal.

@quorak
Copy link

quorak commented Feb 28, 2022

Is there a chance to provide a solution earlier? Do you have a bug bounty program?

@quorak
Copy link

quorak commented Mar 2, 2022

I created a small monkey patch that worked for our case. Looking forward to some feedback.

import { useMemo, useRef, useState } from 'react'
import { isUndefined, omitBy } from 'lodash'

// Unfortunately the current implementation of fullcalendar-react does not honor the `event.id`
// https://github.com/fullcalendar/fullcalendar/issues/7067
// This approach is a inefficient monkey patch which tries to fix the situation
//
// Basic approach is to keep a shadow representation of the events in `oldEventsRef`
// and calculate on every `newEvents` update the added, removed and updated based on the `event.id`.
// With this information we use the calendarApi to update/remove/add the events one by one
// We only update those, where the id is the same, but the object reference differs
//
// Minimal ExampleUsage:
// ```
//   function MyCalendar({events}){
//     const calendarRef = useRef()
//     useFullcalendarEventsMonkeyPatch(
//       events,
//       calendarStore.calendarRef.current,
//     )
//     return <FullCalendar ref={calendarRef} />
//   }
// ```

const DEFAULT_EVENTSOURCE_ID = 'monkeypatch-eventsource'
const DEFAULT_EVENTSOURCE = {
  events: [],
  id: DEFAULT_EVENTSOURCE_ID,
}
export default function useFullcalendarEventsMonkeyPatch(newEvents, calendarRef) {
  const oldEventsRef = useRef([])
  const [resources, setResources] = useState(new Map())
  const calendarApi = calendarRef?.getApi()

  // Make sure resources is set through the calendar callback to be used later
  useMemo(() => {
    if (!calendarApi) return null
    const setResourcesAsMap = (newResources) => setResources(new Map(
      newResources.map((newResource) => [newResource.id, newResource]),
    ))
    calendarApi.on('resourcesSet', setResourcesAsMap)
    return () => calendarApi.off('resourcesSet', setResourcesAsMap)
  }, [calendarApi])

  useMemo(() => {
    if (!calendarRef) return
    if (resources.size === 0) return
    if (newEvents.length === 0 && oldEventsRef.current?.length === 0) return

    // get the oldEvents from previous iteration
    const oldEvents = oldEventsRef.current

    // Calculate the items to be added, removed or updated we use the id in the raw events here
    const added = newEvents.filter((newEvent) => !oldEvents.find((oldEvent) => oldEvent.id === newEvent.id))
    const removed = oldEvents.filter((oldEvent) => !newEvents.find((newEvent) => oldEvent.id === newEvent.id))
    // We only update those, where the id is the same, but the object reference differs
    const updated = newEvents
      .filter((newEvent) => oldEvents.find((oldEvent) => oldEvent.id === newEvent.id) !== newEvent)

    // Get or create the monkeypatch-eventsource
    const eventSource = calendarApi.getEventSourceById(DEFAULT_EVENTSOURCE_ID)
        || calendarApi.addEventSource(DEFAULT_EVENTSOURCE)

    // Execute the addition, removal, updates on the calendar
    added.forEach((event) => calendarApi.addEvent(event, eventSource))
    removed.forEach((event) => calendarApi.getEventById(event.id)?.remove())
    updated.forEach((event) => {
      const calEvent = calendarApi.getEventById(event.id)
      if (!calEvent) return

      const {
        start, end, allDay, resourceIds,
        groupId, title, url, interactive, display, editable, startEditable, durationEditable,
        constraint, overlap, allow, className, classNames, color, backgroundColor, borderColor, textColor,
        ...rest
      } = event
      // Set new times
      calEvent.setStart(start)
      calEvent.setEnd(end)
      calEvent.setAllDay(allDay)
      // Set resources
      if (event.resourceIds) {
        calEvent.setResources(event.resourceIds.map((resourceId) => resources.get(resourceId)))
      }
      // Set event properties
      Object.entries(omitBy({
        groupId,
        title,
        url,
        interactive,
        display,
        editable,
        startEditable,
        durationEditable,
        constraint,
        overlap,
        allow,
        className,
        classNames,
        color,
        backgroundColor,
        borderColor,
        textColor,
      }, isUndefined)).forEach(([name, val]) => {
        calEvent.setProp(name, val)
      })

      // Set event extended properties
      Object.entries(rest).forEach(([name, val]) => {
        calEvent.setExtendedProp(name, val)
      })
    })
    oldEventsRef.current = newEvents
  }, [
    newEvents,
    calendarRef,
    resources,
  ])
}

@Pyakz
Copy link

Pyakz commented Apr 25, 2022

any updates on this one?

@hexsprite
Copy link

I created a small monkey patch that worked for our case. Looking forward to some feedback.
@quorak thanks so much, this sorted me out for my needs right now, looking forward to an actual fix someday :)

@estrellajm
Copy link

estrellajm commented Aug 23, 2022

Same issue on my side. I temporarily solved this by removing the event during the eventChange, sample code below

...
eventChange: this.handleEventChange.bind(this),
...
handleEventChange(arg: EventChangeArg) {
    arg.event.remove(); // due to duplicate bug
...

@arshaw arshaw transferred this issue from fullcalendar/fullcalendar-react Dec 15, 2022
@arshaw
Copy link
Member

arshaw commented Jan 31, 2023

Fixed since v6.1.0

Update repro: https://codesandbox.io/s/test-react-redux-typescript-forked-xki0xc?file=/src/calendar.tsx

(drag the event around rapidly and you'll see duplicate events no longer get created. you will still see the delay in movement because of the delay of the request, but that's expected).

Please ensure that every event object has an id property. This is used identify same event objects between renders where the event array changes. Similar to how key works in React.

Please also ensure you use toPlainEvent when spreading an EventApi's props into your redux store.

I have a note-to-self to make this all clearer in the docs

@arshaw arshaw closed this as completed Jan 31, 2023
@arshaw
Copy link
Member

arshaw commented Jan 31, 2023

That updated repro is still showing some problems. I'll revisit.

@arshaw arshaw reopened this Jan 31, 2023
@linround linround mentioned this issue Jan 31, 2023
Closed
@arshaw arshaw added this to the upcoming-release milestone Jan 31, 2023
@pauldvu
Copy link

pauldvu commented Feb 1, 2023

@arshaw Thanks for the update! I recently solved this issue by lifting state up and was managing my async state manually, but that was a ton of work to get working right. Look forward to implementing changes from v6.1

@arshaw
Copy link
Member

arshaw commented Feb 7, 2023

This is finally fixed in v6.1.3

Update repro:
https://codesandbox.io/s/test-react-redux-typescript-forked-xki0xc?file=/src/calendar.tsx

It's important for everyone to have unique id properties on their events for this to work. Functions almost like a key in react. This is on my todo list to document.

@pauldvu, glad you were able to work around the original issue.

@arshaw arshaw closed this as completed Feb 7, 2023
@cuttingedgecrm
Copy link

cuttingedgecrm commented Feb 11, 2023

Using v6.1.4 and React 18 and still seeing this. I have a unique ID set in the eventData of each new Draggable and getting 5 or 6 events on each drop. Any tips?

https://www.loom.com/share/f8b9dae449ed41f6bfcdc3c339f608d4

@arshaw
Copy link
Member

arshaw commented Mar 21, 2023

@cuttingedgecrm, would you mind opening a new ticket?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests