From 5000811a1aa750d6a992f3220366fdef31eb5063 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 4 Mar 2026 15:19:01 +0100 Subject: [PATCH] Debounce auto save for rapid config changes Prevent excessive save requests when dragging sliders with saveOnSlide enabled. The first change saves immediately to prevent changing behavior for regular updates. Subsequent rapid changes are batched into a single trailing save that fires once changes stop. REDMINE-21191 --- .../mixins/configurationContainer-spec.js | 85 +++++++++++++++++++ .../models/mixins/configurationContainer.js | 45 ++++++++-- 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/package/spec/editor/models/mixins/configurationContainer-spec.js b/package/spec/editor/models/mixins/configurationContainer-spec.js index c7a8986201..bae64e7cf3 100644 --- a/package/spec/editor/models/mixins/configurationContainer-spec.js +++ b/package/spec/editor/models/mixins/configurationContainer-spec.js @@ -102,6 +102,91 @@ describe('configurationContainer', () => { expect(model.save).toHaveBeenCalled(); }); + it('debounces auto save for rapid changes', () => { + jest.useFakeTimers(); + const Model = Backbone.Model.extend({ + mixins: [configurationContainer({autoSave: true})], + }); + const model = new Model({id: 5, configuration: {some: 'value'}}); + model.save = jest.fn(); + + model.configuration.set('some', 'a'); + model.configuration.set('some', 'b'); + model.configuration.set('some', 'c'); + + expect(model.save).toHaveBeenCalledTimes(1); + + jest.runAllTimers(); + + expect(model.save).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + it('resets trailing save delay on each new change', () => { + jest.useFakeTimers(); + const Model = Backbone.Model.extend({ + mixins: [configurationContainer({autoSave: true})], + }); + const model = new Model({id: 5, configuration: {some: 'value'}}); + model.save = jest.fn(); + + model.configuration.set('some', 'a'); + expect(model.save).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(300); + model.configuration.set('some', 'b'); + + jest.advanceTimersByTime(300); + expect(model.save).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(200); + expect(model.save).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + it('does not fire trailing save for single change', () => { + jest.useFakeTimers(); + const Model = Backbone.Model.extend({ + mixins: [configurationContainer({autoSave: true})], + }); + const model = new Model({id: 5, configuration: {some: 'value'}}); + model.save = jest.fn(); + + model.configuration.set('some', 'other value'); + + expect(model.save).toHaveBeenCalledTimes(1); + + jest.runAllTimers(); + + expect(model.save).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it('does not fire trailing save for destroyed model', () => { + jest.useFakeTimers(); + const Model = Backbone.Model.extend({ + mixins: [ + configurationContainer({autoSave: true}), + delayedDestroying + ], + }); + const model = new Model({id: 5, configuration: {some: 'value'}}, {urlRoot: '/models'}); + model.save = jest.fn(); + + model.configuration.set('some', 'a'); + model.configuration.set('some', 'b'); + + expect(model.save).toHaveBeenCalledTimes(1); + + model.destroyWithDelay(); + testContext.requests[0].respond(204, { 'Content-Type': 'application/json' }, ''); + + jest.runAllTimers(); + + expect(model.save).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + it('does not auto save new model', () => { const Model = Backbone.Model.extend({ mixins: [configurationContainer({autoSave: true})], diff --git a/package/src/editor/models/mixins/configurationContainer.js b/package/src/editor/models/mixins/configurationContainer.js index 9a7f8872d8..cb2e811306 100644 --- a/package/src/editor/models/mixins/configurationContainer.js +++ b/package/src/editor/models/mixins/configurationContainer.js @@ -44,14 +44,21 @@ export function configurationContainer({configurationModel, autoSave, includeAtt this.configuration = new configurationModel(this.get('configuration')); this.configuration.parent = this; - this.listenTo(this.configuration, 'change', function(model, options) { - if (!this.isNew() && - (!this.isDestroying || !this.isDestroying()) && - (!this.isDestroyed || !this.isDestroyed()) && - autoSave && - options.autoSave !== false) { + const canSave = () => + !this.isNew() && + (!this.isDestroying || !this.isDestroying()) && + (!this.isDestroyed || !this.isDestroyed()); + + const debouncedSave = leadingTrailingDebounce(() => { + if (canSave()) { this.save(); } + }, 500); + + this.listenTo(this.configuration, 'change', function(model, options) { + if (canSave() && autoSave && options.autoSave !== false) { + debouncedSave(); + } this.trigger('change:configuration', this, undefined, options); @@ -81,3 +88,29 @@ export function configurationContainer({configurationModel, autoSave, includeAtt } }; } + +function leadingTrailingDebounce(fn, delay) { + let timer = null; + let pendingTrailing = false; + + function debounced() { + if (timer === null) { + fn(); + } + else { + pendingTrailing = true; + clearTimeout(timer); + } + + timer = setTimeout(() => { + timer = null; + + if (pendingTrailing) { + pendingTrailing = false; + fn(); + } + }, delay); + } + + return debounced; +}