From f0f61a5a4931245ee145c800123faf6721aeb989 Mon Sep 17 00:00:00 2001 From: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Date: Mon, 26 Aug 2024 13:28:09 +0300 Subject: [PATCH 01/16] Add debounce cancel --- .../@apostrophecms/ui/ui/apos/utils/index.js | 28 +++- test/utils.js | 120 ++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/modules/@apostrophecms/ui/ui/apos/utils/index.js b/modules/@apostrophecms/ui/ui/apos/utils/index.js index 21a32ac9c4..926cdb0401 100644 --- a/modules/@apostrophecms/ui/ui/apos/utils/index.js +++ b/modules/@apostrophecms/ui/ui/apos/utils/index.js @@ -1,10 +1,15 @@ module.exports = { - debounce(fn, delay) { + debounce(fn, delay, options = {}) { + const canceledRejection = new Error('debounce:canceled'); let timer; + let canceled = false; let previousDone = true; const setTimer = (res, rej, args, delay) => { return setTimeout(() => { + if (canceled) { + return rej(canceledRejection); + } if (!previousDone) { clearTimeout(timer); timer = setTimer(res, rej, args, delay); @@ -15,7 +20,12 @@ module.exports = { const returned = fn.apply(this, args); if (returned instanceof Promise) { return returned - .then(res) + .then((result) => { + if (canceled) { + return rej(canceledRejection); + } + res(result); + }) .catch(rej) .finally(() => { previousDone = true; @@ -27,12 +37,24 @@ module.exports = { }, delay); }; - return (...args) => { + function wrapper(...args) { return new Promise((resolve, reject) => { + if (canceled) { + // eslint-disable-next-line prefer-promise-reject-errors + return reject(canceledRejection); + } clearTimeout(timer); timer = setTimer(resolve, reject, args, delay); }); }; + + wrapper.cancel = () => { + canceled = true; + clearTimeout(timer); + timer = null; + }; + + return wrapper; }, throttle(fn, delay) { diff --git a/test/utils.js b/test/utils.js index a66cafdf84..236b243ca4 100644 --- a/test/utils.js +++ b/test/utils.js @@ -441,6 +441,126 @@ describe('Utils', function() { }); + it('should cancel debounced calls (sync)', async function () { + let calledSync = []; + + function syncFn(num) { + calledSync.push(num); + return 'test'; + }; + + const debouncedSync = debounce(syncFn, 50); + + debouncedSync(1); + await wait(200); + debouncedSync(2); + debouncedSync(3); + debouncedSync.cancel(); + assert.deepEqual(calledSync, [ 1 ], 'should cancel all calls after the first call'); + calledSync = []; + + debouncedSync(1); + debouncedSync(2); + debouncedSync(3); + debouncedSync.cancel(); + await wait(200); + assert.deepEqual(calledSync, [], 'should cancel all calls when canceled after the 3rd call'); + calledSync = []; + + debouncedSync(1); + debouncedSync(2); + debouncedSync.cancel(); + debouncedSync(3); + await wait(100); + assert.deepEqual(calledSync, [], 'should cancel all calls when canceled after the 2nd call'); + calledSync = []; + + debouncedSync(1); + debouncedSync(2); + debouncedSync.cancel(); + await wait(100); + debouncedSync(3); + await wait(100); + assert.deepEqual( + calledSync, + [], + 'should cancel all calls when canceled and called again after some time' + ); + }); + + it('should cancel debounced calls (async)', async function () { + let calledAsync = []; + + async function asyncFn(num, time = 50) { + await wait(50); + calledAsync.push(num); + // Keep all console.debug around to easy debug - ensure + // all promises are awaited before the test ends. + console.debug('calledAsync', num); + return 'async'; + } + + const debouncedAsync = debounce(asyncFn, 50); + + debouncedAsync(1); + await wait(100); + debouncedAsync(2); + debouncedAsync(3); + debouncedAsync.cancel(); + await wait(200); + assert.deepEqual(calledAsync, [ 1 ], 'should cancel all calls after the first call'); + calledAsync = []; + + debouncedAsync(1); + debouncedAsync(2); + debouncedAsync(3); + debouncedAsync.cancel(); + await wait(200); + assert.deepEqual(calledAsync, [], 'should cancel all calls when canceled after the 3rd call'); + calledAsync = []; + + debouncedAsync(1); + debouncedAsync(2); + debouncedAsync.cancel(); + debouncedAsync(3); + await wait(200); + assert.deepEqual(calledAsync, [], 'should cancel all calls when canceled after the 2nd call'); + calledAsync = []; + + debouncedAsync(1); + debouncedAsync(2); + debouncedAsync.cancel(); + await wait(200); + debouncedAsync(3); + await wait(100); + assert.deepEqual( + calledAsync, + [], + 'should cancel all calls when canceled and called again after some time' + ); + }); + + it('should reject ongoing promises after canceling debounced calls', async function () { + const calledAsync = []; + async function asyncFn(num, time = 50) { + await wait(time); + calledAsync.push(num); + console.debug('unstoppable async call', num); + return 'async'; + } + + const debouncedAsync = debounce(asyncFn, 50); + const promise = debouncedAsync(1, 300); + await wait(100); + debouncedAsync.cancel(); + + await assert.rejects(promise, { + message: 'debounce:canceled' + }); + + assert.deepEqual(calledAsync, [ 1 ], 'the original promise should always resolve'); + }); + it('can throttle functions', async function () { const calledNormal = []; const calledAsync = []; From 5bfb633b33057c7a0a849eca894d8df4bddf7fca Mon Sep 17 00:00:00 2001 From: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Date: Mon, 26 Aug 2024 16:15:55 +0300 Subject: [PATCH 02/16] Cancel debouncers, guard against memory leaks --- .../ui/apos/components/AposImageCropper.vue | 6 +- .../ui/apos/components/AposMediaManager.vue | 36 ++++- .../ui/apos/components/AposDocsManager.vue | 33 ++++- .../schema/ui/apos/logic/AposInputSlug.js | 28 ++-- .../@apostrophecms/ui/ui/apos/utils/index.js | 135 +++++++++--------- package.json | 3 +- test/utils.js | 16 +-- 7 files changed, 165 insertions(+), 92 deletions(-) diff --git a/modules/@apostrophecms/image/ui/apos/components/AposImageCropper.vue b/modules/@apostrophecms/image/ui/apos/components/AposImageCropper.vue index b15d19f342..73f5db845a 100644 --- a/modules/@apostrophecms/image/ui/apos/components/AposImageCropper.vue +++ b/modules/@apostrophecms/image/ui/apos/components/AposImageCropper.vue @@ -30,7 +30,7 @@