From dfe6063929d11d6675a6b9f8aa5feb5ed0b076b7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 12:00:08 +0300 Subject: [PATCH 1/3] fix(client): spaced update saving more times than necesssary and causing performance issues --- .../client/src/services/spaced_update.spec.ts | 87 +++++++++++++++++++ apps/client/src/services/spaced_update.ts | 10 ++- 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 apps/client/src/services/spaced_update.spec.ts diff --git a/apps/client/src/services/spaced_update.spec.ts b/apps/client/src/services/spaced_update.spec.ts new file mode 100644 index 0000000000..ab649f3435 --- /dev/null +++ b/apps/client/src/services/spaced_update.spec.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import SpacedUpdate from "./spaced_update"; + +// Mock logError which is a global in Trilium +vi.stubGlobal("logError", vi.fn()); + +describe("SpacedUpdate", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should only call updater once per interval even with multiple pending callbacks", async () => { + const updater = vi.fn(async () => { + // Simulate a slow network request - this is where the race condition occurs + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + const spacedUpdate = new SpacedUpdate(updater, 50); + + // Simulate rapid typing - each keystroke calls scheduleUpdate() + // This queues multiple setTimeout callbacks due to recursive scheduleUpdate() calls + for (let i = 0; i < 10; i++) { + spacedUpdate.scheduleUpdate(); + // Small delay between keystrokes + await vi.advanceTimersByTimeAsync(5); + } + + // Advance time past the update interval to trigger the update + await vi.advanceTimersByTimeAsync(100); + + // Let the "network request" complete and any pending callbacks run + await vi.advanceTimersByTimeAsync(200); + + // The updater should have been called only ONCE, not multiple times + // With the bug, multiple pending setTimeout callbacks would all pass the time check + // during the async updater call and trigger multiple concurrent requests + expect(updater).toHaveBeenCalledTimes(1); + }); + + it("should call updater again if changes occur during the update", async () => { + const updater = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const spacedUpdate = new SpacedUpdate(updater, 30); + + // First update + spacedUpdate.scheduleUpdate(); + await vi.advanceTimersByTimeAsync(40); + + // Schedule another update while the first one is in progress + spacedUpdate.scheduleUpdate(); + + // Let first update complete + await vi.advanceTimersByTimeAsync(60); + + // Advance past the interval again for the second update + await vi.advanceTimersByTimeAsync(100); + + // Should have been called twice - once for each distinct change period + expect(updater).toHaveBeenCalledTimes(2); + }); + + it("should restore changed flag on error so retry can happen", async () => { + const updater = vi.fn() + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValue(undefined); + + const spacedUpdate = new SpacedUpdate(updater, 50); + + spacedUpdate.scheduleUpdate(); + + // Advance to trigger first update (which will fail) + await vi.advanceTimersByTimeAsync(60); + + // The error should have restored the changed flag, so scheduling again should work + spacedUpdate.scheduleUpdate(); + await vi.advanceTimersByTimeAsync(60); + + expect(updater).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/client/src/services/spaced_update.ts b/apps/client/src/services/spaced_update.ts index 3804c4949b..13cdbd7b5e 100644 --- a/apps/client/src/services/spaced_update.ts +++ b/apps/client/src/services/spaced_update.ts @@ -77,16 +77,22 @@ export default class SpacedUpdate { } if (Date.now() - this.lastUpdated > this.updateInterval) { + // Update these BEFORE the async call to prevent race conditions. + // Multiple setTimeout callbacks may be pending from recursive scheduleUpdate() calls. + // Without this, they would all pass the time check during the await and trigger multiple requests. + this.lastUpdated = Date.now(); + this.changed = false; + this.onStateChanged("saving"); try { await this.updater(); this.onStateChanged("saved"); - this.changed = false; } catch (e) { + // Restore changed flag on error so a retry can happen + this.changed = true; this.onStateChanged("error"); logError(getErrorMessage(e)); } - this.lastUpdated = Date.now(); } else { // update isn't triggered but changes are still pending, so we need to schedule another check this.scheduleUpdate(); From ff31104b9942b577c9981612c5ddfd8acc0cad20 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 13:31:44 +0300 Subject: [PATCH 2/3] fix(collections/calendar): unnecessary start date set when editing a note in quick edit --- .../src/widgets/collections/calendar/index.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 4bde4b6350..45a430deaa 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -144,7 +144,12 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { + // Only process actual date/time changes, not other property changes (e.g., title via setProp). + const datesChanged = e.oldEvent.start?.getTime() !== e.event.start?.getTime() + || e.oldEvent.end?.getTime() !== e.event.end?.getTime(); + if (!datesChanged) return; + const { startDate, endDate } = parseStartEndDateFromEvent(e.event); if (!startDate) return; From 8c379d03a97392272b9373544a70e8d13eeff972 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 13:41:25 +0300 Subject: [PATCH 3/3] Update apps/client/src/widgets/collections/calendar/index.tsx Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- apps/client/src/widgets/collections/calendar/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 45a430deaa..4594d64564 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -306,7 +306,8 @@ function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean, c const onEventChange = useCallback(async (e: EventChangeArg) => { // Only process actual date/time changes, not other property changes (e.g., title via setProp). const datesChanged = e.oldEvent.start?.getTime() !== e.event.start?.getTime() - || e.oldEvent.end?.getTime() !== e.event.end?.getTime(); + || e.oldEvent.end?.getTime() !== e.event.end?.getTime() + || e.oldEvent.allDay !== e.event.allDay; if (!datesChanged) return; const { startDate, endDate } = parseStartEndDateFromEvent(e.event);