From 5a5bed07e382f0f6ee9f7753f6f03a68231dd234 Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Fri, 23 Jan 2026 17:24:51 +0100 Subject: [PATCH 1/2] fix: wait for previous event to finish before scheduling a new one --- .../pluggableWidgets/events-web/CHANGELOG.md | 4 ++ .../events-web/src/hooks/useOnLoadTimer.ts | 42 +++++++++++++------ 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/pluggableWidgets/events-web/CHANGELOG.md b/packages/pluggableWidgets/events-web/CHANGELOG.md index 53cbdbb9ef..05f2e5a770 100644 --- a/packages/pluggableWidgets/events-web/CHANGELOG.md +++ b/packages/pluggableWidgets/events-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue with burst action execution which was still happening in some cases. + ## [1.2.0] - 2025-11-07 ### Fixed diff --git a/packages/pluggableWidgets/events-web/src/hooks/useOnLoadTimer.ts b/packages/pluggableWidgets/events-web/src/hooks/useOnLoadTimer.ts index d317fda2de..5d8ec324f9 100644 --- a/packages/pluggableWidgets/events-web/src/hooks/useOnLoadTimer.ts +++ b/packages/pluggableWidgets/events-web/src/hooks/useOnLoadTimer.ts @@ -14,6 +14,7 @@ class TimerExecutor { private intervalHandle: ReturnType | undefined; private isFirstTime: boolean = true; private isPendingExecution: boolean = false; + private waitingExecutionToFinish: boolean = false; private canExecute: boolean = false; private delay?: number; @@ -22,10 +23,26 @@ class TimerExecutor { private callback?: () => void; - setCallback(callback: () => void, canExecute: boolean): void { + setCallback(callback: () => void, newCanExecute: boolean): void { this.callback = callback; - this.canExecute = canExecute; + if (this.waitingExecutionToFinish && this.canExecute && !newCanExecute) { + // this means we just executed the command, and canExecute went from true to false + // we should not do anything, only wait for the flag to go back to true + this.canExecute = newCanExecute; + return; + } + + if (this.waitingExecutionToFinish && !this.canExecute && newCanExecute) { + // this means action completed successfully + // we can start a new timer + this.waitingExecutionToFinish = false; + this.canExecute = newCanExecute; + this.next(); + return; + } + + this.canExecute = newCanExecute; this.trigger(); } @@ -34,7 +51,10 @@ class TimerExecutor { this.interval = interval; this.repeat = repeat; - this.next(); + if (this.isFirstTime) { + // kickstart the timer for the first time + this.next(); + } } get isReady(): boolean { @@ -42,7 +62,7 @@ class TimerExecutor { } next(): void { - if (!this.isReady) { + if (!this.isReady || this.waitingExecutionToFinish) { return; } @@ -56,9 +76,8 @@ class TimerExecutor { this.intervalHandle = setTimeout( () => { this.isPendingExecution = true; - this.trigger(); this.isFirstTime = false; - this.next(); + this.trigger(); }, this.isFirstTime ? this.delay : this.interval ); @@ -67,6 +86,7 @@ class TimerExecutor { trigger(): void { if (this.isPendingExecution && this.canExecute) { this.isPendingExecution = false; + this.waitingExecutionToFinish = true; this.callback?.(); } } @@ -77,6 +97,9 @@ class TimerExecutor { this.delay = undefined; this.interval = undefined; this.repeat = false; + this.isFirstTime = true; + this.isPendingExecution = false; + this.waitingExecutionToFinish = false; } } @@ -97,11 +120,4 @@ export function useOnLoadTimer(props: UseOnLoadTimerProps): void { timerExecutor.stop(); }; }, [timerExecutor, delay, interval, repeat]); - - // cleanup - useEffect(() => { - return () => { - timerExecutor.stop(); - }; - }, [timerExecutor]); } From 416f57e472a444d137c57d0bc8785349eb398920 Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Mon, 26 Jan 2026 21:26:57 +0100 Subject: [PATCH 2/2] chore: rewrite on state machine, add tests --- .../events-web/src/helpers/TimerExecutor.ts | 99 ++++ .../helpers/__tests__/TimerExecutor.spec.ts | 471 ++++++++++++++++++ .../events-web/src/hooks/useOnLoadTimer.ts | 94 +--- 3 files changed, 571 insertions(+), 93 deletions(-) create mode 100644 packages/pluggableWidgets/events-web/src/helpers/TimerExecutor.ts create mode 100644 packages/pluggableWidgets/events-web/src/helpers/__tests__/TimerExecutor.spec.ts diff --git a/packages/pluggableWidgets/events-web/src/helpers/TimerExecutor.ts b/packages/pluggableWidgets/events-web/src/helpers/TimerExecutor.ts new file mode 100644 index 0000000000..b50cf25700 --- /dev/null +++ b/packages/pluggableWidgets/events-web/src/helpers/TimerExecutor.ts @@ -0,0 +1,99 @@ +type TimerState = "initial" | "idle" | "scheduled" | "pending" | "invoking" | "executing" | "completed"; + +export class TimerExecutor { + private state: TimerState = "initial"; + private intervalHandle: ReturnType | undefined; + private canExecute: boolean = false; + + private delay?: number; + private interval?: number; + private repeat?: boolean; + + private callback?: () => void; + + setCallback(callback: () => void, newCanExecute: boolean): void { + this.callback = callback; + const prevCanExecute = this.canExecute; + this.canExecute = newCanExecute; + + if (this.state === "invoking") { + if (prevCanExecute && !newCanExecute) { + // Action just started executing (canExecute went from true to false) + this.onExecutionStart(); + return; + } + } + + if (this.state === "executing") { + if (!prevCanExecute && newCanExecute) { + // Action completed successfully (canExecute went from false to true) + this.onExecutionFinish(); + return; + } + } + + this.tryExecute(); + } + + setParams(delay: number | undefined, interval: number | undefined, repeat: boolean): void { + this.delay = delay; + this.interval = interval; + this.repeat = repeat; + + if (this.state === "initial") { + this.scheduleNext(); + } + } + + get isReady(): boolean { + return this.delay !== undefined && (!this.repeat || this.interval !== undefined); + } + + stop(): void { + clearTimeout(this.intervalHandle); + this.intervalHandle = undefined; + this.delay = undefined; + this.interval = undefined; + this.repeat = false; + this.state = "initial"; + } + + private scheduleNext(): void { + if (!this.isReady || this.state === "executing" || this.state === "completed") { + return; + } + + const isFirstTime = this.state === "initial"; + const timeout = isFirstTime ? this.delay : this.interval; + + this.state = "scheduled"; + this.intervalHandle = setTimeout(() => { + this.onTimerFired(); + }, timeout); + } + + private onTimerFired(): void { + this.state = "pending"; + this.tryExecute(); + } + + private tryExecute(): void { + if (this.state === "pending" && this.canExecute && this.callback) { + this.state = "invoking"; + this.callback(); + } + } + + private onExecutionStart(): void { + this.state = "executing"; + } + + private onExecutionFinish(): void { + if (this.repeat) { + this.state = "idle"; + this.scheduleNext(); + } else { + this.state = "completed"; + } + } +} diff --git a/packages/pluggableWidgets/events-web/src/helpers/__tests__/TimerExecutor.spec.ts b/packages/pluggableWidgets/events-web/src/helpers/__tests__/TimerExecutor.spec.ts new file mode 100644 index 0000000000..56487685f9 --- /dev/null +++ b/packages/pluggableWidgets/events-web/src/helpers/__tests__/TimerExecutor.spec.ts @@ -0,0 +1,471 @@ +import { TimerExecutor } from "../TimerExecutor"; + +describe("TimerExecutor", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe("Basic functionality", () => { + it("should start in idle state", () => { + const executor = new TimerExecutor(); + expect(executor.isReady).toBe(false); + }); + + it("should become ready after setting params", () => { + const executor = new TimerExecutor(); + executor.setParams(1000, undefined, false); + expect(executor.isReady).toBe(true); + }); + + it("should require interval when repeat is true", () => { + const executor = new TimerExecutor(); + executor.setParams(1000, undefined, true); + expect(executor.isReady).toBe(false); + + executor.setParams(1000, 2000, true); + expect(executor.isReady).toBe(true); + }); + }); + + describe("Single execution (non-repeating)", () => { + it("should execute callback after delay when canExecute is true", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + // Setup + executor.setParams(1000, undefined, false); + executor.setCallback(callback, true); + + // Fast-forward time + jest.advanceTimersByTime(1200); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should not execute callback if canExecute is false", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + executor.setParams(1000, undefined, false); + executor.setCallback(callback, false); + + jest.advanceTimersByTime(1200); + + expect(callback).not.toHaveBeenCalled(); + }); + + it("should wait for canExecute to become true before executing", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + executor.setParams(1000, undefined, false); + executor.setCallback(callback, false); + + // Timer fires but canExecute is false + jest.advanceTimersByTime(1200); + expect(callback).not.toHaveBeenCalled(); + + // canExecute becomes true + executor.setCallback(callback, true); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should transition through states: idle -> scheduled -> pending -> invoking -> executing -> completed", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + // idle state + expect(executor.isReady).toBe(false); + + // idle -> scheduled + executor.setParams(1000, undefined, false); + executor.setCallback(callback, true); + + // scheduled -> pending -> invoking (after timer fires) + jest.advanceTimersByTime(1200); + expect(callback).toHaveBeenCalledTimes(1); + + // invoking -> executing (canExecute goes false) + executor.setCallback(callback, false); + + // executing -> completed (canExecute goes true) + executor.setCallback(callback, true); + + // Should not execute again (completed state) + jest.advanceTimersByTime(10000); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe("Repeated execution", () => { + it("should execute callback repeatedly with interval", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + executor.setParams(1000, 2000, true); + executor.setCallback(callback, true); + + // First execution after delay + jest.advanceTimersByTime(1100); + expect(callback).toHaveBeenCalledTimes(1); + + // Simulate action execution cycle + executor.setCallback(callback, false); // Action starts + executor.setCallback(callback, true); // Action completes + + // Still no second execution + jest.advanceTimersByTime(1100); + expect(callback).toHaveBeenCalledTimes(1); + + // Second execution after interval + jest.advanceTimersByTime(1100); + expect(callback).toHaveBeenCalledTimes(2); + + // Simulate action execution cycle + executor.setCallback(callback, false); + executor.setCallback(callback, true); + + // Third execution after interval + jest.advanceTimersByTime(2100); + expect(callback).toHaveBeenCalledTimes(3); + }); + + it("should use delay for first execution and interval for subsequent", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + executor.setParams(500, 2000, true); + executor.setCallback(callback, true); + + // First: 500ms + jest.advanceTimersByTime(510); + expect(callback).toHaveBeenCalledTimes(1); + + executor.setCallback(callback, false); + executor.setCallback(callback, true); + + // Still not executed + jest.advanceTimersByTime(600); + expect(callback).toHaveBeenCalledTimes(1); + + // Executed + jest.advanceTimersByTime(1500); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it("should not schedule next execution while action is executing", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + executor.setParams(1000, 1000, true); + executor.setCallback(callback, true); + + // First execution + jest.advanceTimersByTime(1100); + expect(callback).toHaveBeenCalledTimes(1); + + // Action starts executing + executor.setCallback(callback, false); + + // Time passes but action is still executing + jest.advanceTimersByTime(5000); + expect(callback).toHaveBeenCalledTimes(1); + + // Action completes + executor.setCallback(callback, true); + + // Not yet executed + jest.advanceTimersByTime(900); + expect(callback).toHaveBeenCalledTimes(1); + + // Now next execution should be scheduled + jest.advanceTimersByTime(200); + expect(callback).toHaveBeenCalledTimes(2); + }); + }); + + describe("State transitions and edge cases", () => { + it("should handle canExecute changes during invoking state", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + executor.setParams(1000, undefined, false); + executor.setCallback(callback, true); + + // Timer fires, callback invoked (invoking state) + jest.advanceTimersByTime(1100); + expect(callback).toHaveBeenCalledTimes(1); + + // Simulate rapid canExecute changes during action start + executor.setCallback(callback, true); // Still true + executor.setCallback(callback, false); // Action starts (invoking -> executing) + executor.setCallback(callback, false); // Still false + executor.setCallback(callback, true); // Action completes (executing -> completed) + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should handle canExecute changes during executing state", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + executor.setParams(1000, 2000, true); + executor.setCallback(callback, true); + + jest.advanceTimersByTime(1100); + expect(callback).toHaveBeenCalledTimes(1); + + // Enter executing state + executor.setCallback(callback, false); + + // Multiple updates while executing + executor.setCallback(callback, false); + executor.setCallback(callback, false); + + // Action completes + executor.setCallback(callback, true); + + // Should schedule next + jest.advanceTimersByTime(2100); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it("should handle callback updates during lifecycle", () => { + const executor = new TimerExecutor(); + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + executor.setParams(1000, undefined, false); + executor.setCallback(callback1, true); + + jest.advanceTimersByTime(510); + + // Update callback before timer fires + executor.setCallback(callback2, true); + + jest.advanceTimersByTime(510); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + }); + }); + + describe("Stop functionality", () => { + it("should cancel scheduled execution", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + executor.setParams(1000, undefined, false); + executor.setCallback(callback, true); + + jest.advanceTimersByTime(500); + executor.stop(); + + jest.advanceTimersByTime(1000); + expect(callback).not.toHaveBeenCalled(); + }); + + it("should reset to idle state", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + executor.setParams(1000, undefined, false); + executor.setCallback(callback, true); + + executor.stop(); + + expect(executor.isReady).toBe(false); + }); + + it("should allow restart after stop", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + executor.setParams(1000, undefined, false); + executor.setCallback(callback, true); + + jest.advanceTimersByTime(500); + executor.stop(); + + // Restart + executor.setParams(500, undefined, false); + executor.setCallback(callback, true); + + jest.advanceTimersByTime(510); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe("React-like lifecycle simulation", () => { + it("should handle mount, unmount, remount pattern", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + // Mount: initialize with params and callback + executor.setParams(1000, undefined, false); + executor.setCallback(callback, true); + + jest.advanceTimersByTime(500); + + // Unmount: cleanup + executor.stop(); + + jest.advanceTimersByTime(1000); + expect(callback).not.toHaveBeenCalled(); + + // Remount: reinitialize + executor.setParams(500, undefined, false); + executor.setCallback(callback, true); + + jest.advanceTimersByTime(510); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should handle prop updates during lifecycle", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + // Initial mount + executor.setParams(1000, 2000, true); + executor.setCallback(callback, true); + + // First execution + jest.advanceTimersByTime(1100); + expect(callback).toHaveBeenCalledTimes(1); + + executor.setCallback(callback, false); + executor.setCallback(callback, true); + + // Props update: change delay/interval + executor.stop(); + executor.setParams(500, 1000, true); + executor.setCallback(callback, true); + + // Next execution with new timing + jest.advanceTimersByTime(510); + expect(callback).toHaveBeenCalledTimes(2); + + executor.setCallback(callback, false); + executor.setCallback(callback, true); + + jest.advanceTimersByTime(1000); + expect(callback).toHaveBeenCalledTimes(3); + }); + + it("should handle rapid prop updates", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + // First set of params + executor.setParams(1000, undefined, false); + executor.setCallback(callback, true); + + jest.advanceTimersByTime(500); + + // Rapid prop updates (like React re-renders) + executor.stop(); + executor.setParams(2000, undefined, false); + executor.setCallback(callback, true); + + jest.advanceTimersByTime(1000); + expect(callback).not.toHaveBeenCalled(); + + // Final update with shorter delay + executor.stop(); + executor.setParams(500, undefined, false); + executor.setCallback(callback, true); + + // still not called + jest.advanceTimersByTime(450); + expect(callback).not.toHaveBeenCalled(); + + // finally called + jest.advanceTimersByTime(100); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should handle cleanup during action execution", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + executor.setParams(1000, 2000, true); + executor.setCallback(callback, true); + + jest.advanceTimersByTime(1000); + expect(callback).toHaveBeenCalledTimes(1); + + // Action starts + executor.setCallback(callback, false); + + // Component unmounts while action is executing + executor.stop(); + + // Action completes but component is unmounted + // Should not schedule next execution + jest.advanceTimersByTime(5000); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe("Edge cases and error scenarios", () => { + it("should handle missing callback gracefully", () => { + const executor = new TimerExecutor(); + + executor.setParams(1000, undefined, false); + + // No callback set, should not throw + expect(() => { + jest.advanceTimersByTime(1100); + }).not.toThrow(); + }); + + it("should handle undefined params", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + executor.setParams(undefined, undefined, false); + executor.setCallback(callback, true); + + expect(executor.isReady).toBe(false); + + jest.advanceTimersByTime(5000); + expect(callback).not.toHaveBeenCalled(); + }); + + it("should handle zero delay", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + executor.setParams(0, undefined, false); + executor.setCallback(callback, true); + + jest.advanceTimersByTime(0); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should not execute if action never completes", () => { + const executor = new TimerExecutor(); + const callback = jest.fn(); + + executor.setParams(1000, 1000, true); + executor.setCallback(callback, true); + + jest.advanceTimersByTime(1000); + expect(callback).toHaveBeenCalledTimes(1); + + // Action starts but never completes + executor.setCallback(callback, false); + + // Time passes indefinitely + jest.advanceTimersByTime(100000); + + // Should still only have executed once + expect(callback).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/pluggableWidgets/events-web/src/hooks/useOnLoadTimer.ts b/packages/pluggableWidgets/events-web/src/hooks/useOnLoadTimer.ts index 5d8ec324f9..fb339e0734 100644 --- a/packages/pluggableWidgets/events-web/src/hooks/useOnLoadTimer.ts +++ b/packages/pluggableWidgets/events-web/src/hooks/useOnLoadTimer.ts @@ -1,5 +1,6 @@ import { EditableValue } from "mendix"; import { useEffect, useState } from "react"; +import { TimerExecutor } from "../helpers/TimerExecutor"; interface UseOnLoadTimerProps { canExecute: boolean; @@ -10,99 +11,6 @@ interface UseOnLoadTimerProps { attribute?: EditableValue; } -class TimerExecutor { - private intervalHandle: ReturnType | undefined; - private isFirstTime: boolean = true; - private isPendingExecution: boolean = false; - private waitingExecutionToFinish: boolean = false; - private canExecute: boolean = false; - - private delay?: number; - private interval?: number; - private repeat?: boolean; - - private callback?: () => void; - - setCallback(callback: () => void, newCanExecute: boolean): void { - this.callback = callback; - - if (this.waitingExecutionToFinish && this.canExecute && !newCanExecute) { - // this means we just executed the command, and canExecute went from true to false - // we should not do anything, only wait for the flag to go back to true - this.canExecute = newCanExecute; - return; - } - - if (this.waitingExecutionToFinish && !this.canExecute && newCanExecute) { - // this means action completed successfully - // we can start a new timer - this.waitingExecutionToFinish = false; - this.canExecute = newCanExecute; - this.next(); - return; - } - - this.canExecute = newCanExecute; - this.trigger(); - } - - setParams(delay: number | undefined, interval: number | undefined, repeat: boolean): void { - this.delay = delay; - this.interval = interval; - this.repeat = repeat; - - if (this.isFirstTime) { - // kickstart the timer for the first time - this.next(); - } - } - - get isReady(): boolean { - return this.delay !== undefined && (!this.repeat || this.interval !== undefined); - } - - next(): void { - if (!this.isReady || this.waitingExecutionToFinish) { - return; - } - - if (!this.isFirstTime && !this.repeat) { - // we did execute it once, and we don't need to repeat - // so do nothing - return; - } - - // schedule a timer - this.intervalHandle = setTimeout( - () => { - this.isPendingExecution = true; - this.isFirstTime = false; - this.trigger(); - }, - this.isFirstTime ? this.delay : this.interval - ); - } - - trigger(): void { - if (this.isPendingExecution && this.canExecute) { - this.isPendingExecution = false; - this.waitingExecutionToFinish = true; - this.callback?.(); - } - } - - stop(): void { - clearTimeout(this.intervalHandle); - this.intervalHandle = undefined; - this.delay = undefined; - this.interval = undefined; - this.repeat = false; - this.isFirstTime = true; - this.isPendingExecution = false; - this.waitingExecutionToFinish = false; - } -} - export function useOnLoadTimer(props: UseOnLoadTimerProps): void { const { canExecute, execute, delay, interval, repeat, attribute } = props;