Skip to content

fix(calendar): stop EditEvent writing stale fields over concurrent edits - #11011

Closed
clayrisser wants to merge 1 commit into
hcengineering:developfrom
clayrisser:fix/edit-event-initial-snapshot
Closed

fix(calendar): stop EditEvent writing stale fields over concurrent edits#11011
clayrisser wants to merge 1 commit into
hcengineering:developfrom
clayrisser:fix/edit-event-initial-snapshot

Conversation

@clayrisser

Copy link
Copy Markdown

Problem

EditEvent.svelte seeds its editor state from object once, at component initialisation:

export let object: Event
…
let title = object.title
let description = object.description
// …visibility, calendar, location, timeZone, allDay, dates, participants,
//   externalParticipants, reminders, rules

But object is a live prop. The surrounding object-editor query re-assigns it whenever the event changes on the server. saveEvent then diffs the editor state against that live document rather than against the snapshot it was seeded from (EditEvent.svelte:81-123):

if (object.title !== title) {
  update.title = title.trim()
}
if (object.description !== description) {
  update.description = description.trim()
}
// …thirteen comparisons, all against the live `object`

So if another writer changes a field while the panel is open, that field now differs from the mount-time value the editor is still holding — which reads as "the user edited this" — and the stale mount-time value is written back over the newer one on the next Save.

The user gets no warning and sees no error. The save does exactly what it was asked to do.

All thirteen fields are affected: title, description, visibility, calendar, location, timeZone, allDay, date, dueDate, participants, externalParticipants, reminders, and a recurring event's rules.

Reproduction

  1. Open an event in the event editor and leave the panel open. Do not touch the title.
  2. From anywhere else — a second browser, another user, the calendar integration, or any other writer — change that event's title.
  3. In the still-open panel, edit only the location, and Save.
  4. The title reverts to what it was when the panel was opened.

Any concurrent writer reaches this: a second user, a second tab, or a sync pod writing back from an external calendar.

Fix

Capture the seed document, and diff against that:

   export let object: Event
   $: readOnly = isReadOnly(object)
 
+  // The fields below are a one-time snapshot of the document, but `object` is a live
+  // prop that the surrounding query re-assigns whenever the event changes on the
+  // server. saveEvent must diff the editor state against the snapshot it was seeded
+  // from, not against the live document, or an untouched field is written back with
+  // its stale value and clobbers whatever another client just wrote.
+  const initial: Event = { ...object }
+
   let title = object.title

and then object.initial. in the thirteen comparisons inside saveEvent. A field the user did not touch now compares equal, stays out of update entirely, and the concurrent write survives.

The rest of the component is untouched: object remains the live prop everywhere else it is used, including readOnly.

Scope and residual risk

  • This is last-writer-wins per field, not real conflict resolution. If the user genuinely edited a field and someone else edited the same field, the user's value still wins. That is unchanged and is a much narrower behaviour than what happens today, where fields the user never looked at are also overwritten.
  • Untouched fields are now simply absent from update, so they are not written at all — which is the correct outcome and also reduces the size of the update transaction.
  • Only saveEvent's comparison operands change; no new state, no new subscriptions, no lifecycle changes.

One narrow sub-case worth naming: if a field was empty at mount and another writer set it since, the fix keeps it out of update entirely, so the newer value survives. Under the current code it would be written back as undefined. Whether that undefined currently unsets the field or is dropped by the ops layer, I have not tested — and the fix makes the question moot in both directions, because an untouched field never enters update at all.

Verification

Verified against develop @ 1be6047c8: all thirteen comparisons in saveEvent still diff against the live object, and object is still a live prop. git apply is clean.

No automated test is offered, and I want to be straight about why: plugins/calendar-resources has no jest project, and reproducing this needs a mounted Svelte component with a concurrently-mutating prop. The change is mechanical — one added line plus a rename of the comparison operand in thirteen places — and the reproduction above is the evidence. If you would like a test and can point me at the harness you would want it in, I am happy to add one.

Provenance

Found while auditing write paths on a self-hosted deployment that has a second writer to calendar events. Reported as a code-reading result plus the manual reproduction above; I have not measured how often it fires in normal use.

EditEvent seeds its editor state (title, description, dates, participants,
...) from `object` once, at component init. `object` is a live prop that
the object-editor query re-assigns whenever the event changes on the
server. saveEvent then diffs the one-time snapshot against the *live*
document, so any field another client changed while the panel was open
reads as "the user edited this" and is written back with the mount-time
value.

Capture the seed document and diff against that instead: a field the user
did not touch now compares equal and is left out of the update, so the
concurrent write survives.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Clay Risser <clayrisser@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@clayrisser
clayrisser force-pushed the fix/edit-event-initial-snapshot branch from fb0ea2c to 2123b18 Compare August 13, 2026 06:47
@clayrisser

Copy link
Copy Markdown
Author

Closing — this was opened by an automated agent without my intent. Apologies for the noise.

@clayrisser clayrisser closed this Aug 14, 2026
@clayrisser
clayrisser deleted the fix/edit-event-initial-snapshot branch August 14, 2026 20:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant