├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── index.d.ts ├── index.js ├── index.test-d.ts ├── license ├── package.json ├── readme.md └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 24 14 | - 20 15 | steps: 16 | - uses: actions/checkout@v5 17 | - uses: actions/setup-node@v5 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export type Options = { 2 | /** 3 | Call the `fn` on the [leading edge of the timeout](https://css-tricks.com/debouncing-throttling-explained-examples/#article-header-id-1). Meaning immediately, instead of waiting for `wait` milliseconds. 4 | 5 | @default false 6 | */ 7 | readonly before?: boolean; 8 | 9 | /** 10 | An `AbortSignal` to cancel the debounced function. 11 | */ 12 | readonly signal?: AbortSignal; 13 | }; 14 | 15 | export type PromiseOptions = { 16 | /** 17 | If a call is made while a previous call is still running, queue the latest arguments and run the function again after the current execution completes. 18 | 19 | @default false 20 | 21 | Use cases: 22 | - With `after: false` (default): API fetches, data loading, read operations - concurrent calls share the same result. 23 | - With `after: true`: Saving data, file writes, state updates - ensures latest data is never lost. 24 | 25 | @example 26 | ``` 27 | import {setTimeout as delay} from 'timers/promises'; 28 | import pDebounce from 'p-debounce'; 29 | 30 | const save = async data => { 31 | await delay(200); 32 | console.log(`Saved: ${data}`); 33 | return data; 34 | }; 35 | 36 | const debouncedSave = pDebounce.promise(save, {after: true}); 37 | 38 | // If data changes while saving, it will save again with the latest data 39 | debouncedSave('data1'); 40 | debouncedSave('data2'); // This will run after the first save completes 41 | //=> Saved: data1 42 | //=> Saved: data2 43 | ``` 44 | */ 45 | readonly after?: boolean; 46 | }; 47 | 48 | declare const pDebounce: { 49 | /** 50 | [Debounce](https://css-tricks.com/debouncing-throttling-explained-examples/) promise-returning & async functions. 51 | 52 | @param fn - Promise-returning/async function to debounce. 53 | @param wait - Milliseconds to wait before calling `fn`. 54 | @returns A function that delays calling `fn` until after `wait` milliseconds have elapsed since the last time it was called. 55 | 56 | @example 57 | ``` 58 | import pDebounce from 'p-debounce'; 59 | 60 | const expensiveCall = async input => input; 61 | 62 | const debouncedFunction = pDebounce(expensiveCall, 200); 63 | 64 | for (const number of [1, 2, 3]) { 65 | (async () => { 66 | console.log(await debouncedFunction(number)); 67 | })(); 68 | } 69 | //=> 3 70 | //=> 3 71 | //=> 3 72 | ``` 73 | */ 74 | ( 75 | fn: (this: This, ...arguments: ArgumentsType) => PromiseLike | ReturnType, 76 | wait: number, 77 | options?: Options 78 | ): (this: This, ...arguments: ArgumentsType) => Promise; 79 | 80 | /** 81 | Execute `function_` unless a previous call is still pending, in which case, return the pending promise. Useful, for example, to avoid processing extra button clicks if the previous one is not complete. 82 | 83 | @param function_ - Promise-returning/async function to debounce. 84 | 85 | @example 86 | ``` 87 | import {setTimeout as delay} from 'timers/promises'; 88 | import pDebounce from 'p-debounce'; 89 | 90 | const expensiveCall = async value => { 91 | await delay(200); 92 | return value; 93 | }; 94 | 95 | const debouncedFunction = pDebounce.promise(expensiveCall); 96 | 97 | for (const number of [1, 2, 3]) { 98 | (async () => { 99 | console.log(await debouncedFunction(number)); 100 | })(); 101 | } 102 | //=> 1 103 | //=> 1 104 | //=> 1 105 | ``` 106 | */ 107 | promise( 108 | function_: (this: This, ...arguments: ArgumentsType) => PromiseLike | ReturnType, 109 | options?: PromiseOptions 110 | ): (this: This, ...arguments: ArgumentsType) => Promise; 111 | }; 112 | 113 | export default pDebounce; 114 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const pDebounce = (functionToDebounce, wait, options = {}) => { 2 | if (!Number.isFinite(wait)) { 3 | throw new TypeError('Expected `wait` to be a finite number'); 4 | } 5 | 6 | let leadingValue; 7 | let timeout; 8 | let promiseHandlers = []; // Single array of {resolve, reject} 9 | 10 | const onAbort = () => { 11 | clearTimeout(timeout); 12 | timeout = undefined; 13 | 14 | try { 15 | options.signal?.throwIfAborted(); 16 | } catch (error) { 17 | for (const {reject} of promiseHandlers) { 18 | reject(error); 19 | } 20 | 21 | promiseHandlers = []; 22 | } 23 | }; 24 | 25 | return function (...arguments_) { 26 | return new Promise((resolve, reject) => { 27 | // Check if already aborted 28 | try { 29 | options.signal?.throwIfAborted(); 30 | } catch (error) { 31 | reject(error); 32 | return; 33 | } 34 | 35 | const shouldCallNow = options.before && !timeout; 36 | 37 | clearTimeout(timeout); 38 | 39 | timeout = setTimeout(async () => { 40 | timeout = undefined; 41 | 42 | // Capture the current handlers for this execution 43 | const currentHandlers = promiseHandlers; 44 | 45 | // Clear handlers for next cycle (new calls during execution will add to new list) 46 | promiseHandlers = []; 47 | 48 | try { 49 | const result = options.before ? leadingValue : await functionToDebounce.apply(this, arguments_); 50 | 51 | for (const {resolve: resolveFunction} of currentHandlers) { 52 | resolveFunction(result); 53 | } 54 | } catch (error) { 55 | for (const {reject: rejectFunction} of currentHandlers) { 56 | rejectFunction(error); 57 | } 58 | } 59 | 60 | // Clear leading value for next cycle 61 | leadingValue = undefined; 62 | 63 | // Remove abort listener 64 | options.signal?.removeEventListener('abort', onAbort); 65 | }, wait); 66 | 67 | if (shouldCallNow) { 68 | // Execute immediately for leading edge 69 | (async () => { 70 | try { 71 | leadingValue = await functionToDebounce.apply(this, arguments_); 72 | resolve(leadingValue); 73 | } catch (error) { 74 | reject(error); 75 | } 76 | })(); 77 | } else { 78 | // Add to handlers for later resolution 79 | promiseHandlers.push({resolve, reject}); 80 | 81 | // Set up abort listener (only once per batch) 82 | if (options.signal && promiseHandlers.length === 1) { 83 | options.signal.addEventListener('abort', onAbort, {once: true}); 84 | } 85 | } 86 | }); 87 | }; 88 | }; 89 | 90 | pDebounce.promise = (function_, options = {}) => { 91 | let currentPromise; 92 | let queuedCall; 93 | 94 | return async function (...arguments_) { 95 | if (currentPromise) { 96 | if (!options.after) { 97 | return currentPromise; 98 | } 99 | 100 | // Queue latest call (replacing any existing queue) 101 | queuedCall ??= {resolvers: []}; 102 | queuedCall.arguments = arguments_; 103 | queuedCall.context = this; 104 | 105 | return new Promise((resolve, reject) => { 106 | queuedCall.resolvers.push({resolve, reject}); 107 | }); 108 | } 109 | 110 | currentPromise = (async () => { 111 | let result; 112 | let initialError; 113 | 114 | try { 115 | result = await function_.apply(this, arguments_); 116 | } catch (error) { 117 | initialError = error; 118 | } 119 | 120 | // Process queued calls regardless of initial result 121 | while (queuedCall) { 122 | const call = queuedCall; 123 | queuedCall = undefined; 124 | 125 | try { 126 | // eslint-disable-next-line no-await-in-loop 127 | const queuedResult = await function_.apply(call.context, call.arguments); 128 | for (const {resolve} of call.resolvers) { 129 | resolve(queuedResult); 130 | } 131 | } catch (error) { 132 | for (const {reject} of call.resolvers) { 133 | reject(error); 134 | } 135 | } 136 | } 137 | 138 | if (initialError) { 139 | throw initialError; 140 | } 141 | 142 | return result; 143 | })(); 144 | 145 | try { 146 | return await currentPromise; 147 | } finally { 148 | currentPromise = undefined; 149 | } 150 | }; 151 | }; 152 | 153 | export default pDebounce; 154 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import {expectType} from 'tsd'; 2 | import pDebounce from './index.js'; 3 | 4 | const expensiveCall = async (input: number) => input; 5 | 6 | // Test basic return type 7 | expectType<(input: number) => Promise>(pDebounce(expensiveCall, 200)); 8 | 9 | // Test with signal option 10 | const controller = new AbortController(); 11 | expectType<(input: number) => Promise>(pDebounce(expensiveCall, 200, {signal: controller.signal})); 12 | 13 | // Test with before option 14 | expectType<(input: number) => Promise>(pDebounce(expensiveCall, 200, {before: true})); 15 | 16 | // Test promise method 17 | expectType<(input: number) => Promise>(pDebounce.promise(expensiveCall)); 18 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "p-debounce", 3 | "version": "5.1.0", 4 | "description": "Debounce promise-returning & async functions", 5 | "license": "MIT", 6 | "repository": "sindresorhus/p-debounce", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": { 15 | "types": "./index.d.ts", 16 | "default": "./index.js" 17 | }, 18 | "sideEffects": false, 19 | "engines": { 20 | "node": ">=20" 21 | }, 22 | "scripts": { 23 | "test": "xo && node --test && tsd" 24 | }, 25 | "files": [ 26 | "index.js", 27 | "index.d.ts" 28 | ], 29 | "keywords": [ 30 | "promise", 31 | "debounce", 32 | "debounced", 33 | "limit", 34 | "limited", 35 | "concurrency", 36 | "throttle", 37 | "throat", 38 | "interval", 39 | "rate", 40 | "batch", 41 | "ratelimit", 42 | "task", 43 | "queue", 44 | "async", 45 | "await", 46 | "promises", 47 | "bluebird" 48 | ], 49 | "devDependencies": { 50 | "tsd": "^0.33.0", 51 | "xo": "^1.2.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # p-debounce 2 | 3 | > [Debounce](https://css-tricks.com/debouncing-throttling-explained-examples/) promise-returning & async functions 4 | 5 | ## Install 6 | 7 | ```sh 8 | npm install p-debounce 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | import pDebounce from 'p-debounce'; 15 | 16 | const expensiveCall = async input => input; 17 | 18 | const debouncedFunction = pDebounce(expensiveCall, 200); 19 | 20 | for (const number of [1, 2, 3]) { 21 | (async () => { 22 | console.log(await debouncedFunction(number)); 23 | })(); 24 | } 25 | //=> 3 26 | //=> 3 27 | //=> 3 28 | ``` 29 | 30 | ## API 31 | 32 | ### pDebounce(fn, wait, options?) 33 | 34 | Returns a function that delays calling `fn` until after `wait` milliseconds have elapsed since the last time it was called. 35 | 36 | #### fn 37 | 38 | Type: `Function` 39 | 40 | Promise-returning/async function to debounce. 41 | 42 | #### wait 43 | 44 | Type: `number` 45 | 46 | Milliseconds to wait before calling `fn`. 47 | 48 | #### options 49 | 50 | Type: `object` 51 | 52 | ##### before 53 | 54 | Type: `boolean`\ 55 | Default: `false` 56 | 57 | Call the `fn` on the [leading edge of the timeout](https://css-tricks.com/debouncing-throttling-explained-examples/#article-header-id-1). Meaning immediately, instead of waiting for `wait` milliseconds. 58 | 59 | ##### signal 60 | 61 | Type: `AbortSignal` 62 | 63 | An [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) to cancel the debounced function. 64 | 65 | ### pDebounce.promise(function_, options?) 66 | 67 | Execute `function_` unless a previous call is still pending, in which case, return the pending promise. Useful, for example, to avoid processing extra button clicks if the previous one is not complete. 68 | 69 | ```js 70 | import {setTimeout as delay} from 'timers/promises'; 71 | import pDebounce from 'p-debounce'; 72 | 73 | const expensiveCall = async value => { 74 | await delay(200); 75 | return value; 76 | }; 77 | 78 | const debouncedFunction = pDebounce.promise(expensiveCall); 79 | 80 | for (const number of [1, 2, 3]) { 81 | (async () => { 82 | console.log(await debouncedFunction(number)); 83 | })(); 84 | } 85 | //=> 1 86 | //=> 1 87 | //=> 1 88 | ``` 89 | 90 | #### function_ 91 | 92 | Type: `Function` 93 | 94 | Promise-returning/async function to debounce. 95 | 96 | #### options 97 | 98 | Type: `object` 99 | 100 | ##### after 101 | 102 | Type: `boolean`\ 103 | Default: `false` 104 | 105 | If a call is made while a previous call is still running, queue the latest arguments and run the function again after the current execution completes. 106 | 107 | Use cases: 108 | - With `after: false` (default): API fetches, data loading, read operations - concurrent calls share the same result. 109 | - With `after: true`: Saving data, file writes, state updates - ensures latest data is never lost. 110 | 111 | ```js 112 | import {setTimeout as delay} from 'timers/promises'; 113 | import pDebounce from 'p-debounce'; 114 | 115 | const save = async data => { 116 | await delay(200); 117 | console.log(`Saved: ${data}`); 118 | return data; 119 | }; 120 | 121 | const debouncedSave = pDebounce.promise(save, {after: true}); 122 | 123 | // If data changes while saving, it will save again with the latest data 124 | debouncedSave('data1'); 125 | debouncedSave('data2'); // This will run after the first save completes 126 | //=> Saved: data1 127 | //=> Saved: data2 128 | ``` 129 | 130 | ## Related 131 | 132 | - [p-throttle](https://github.com/sindresorhus/p-throttle) - Throttle promise-returning & async functions 133 | - [p-limit](https://github.com/sindresorhus/p-limit) - Run multiple promise-returning & async functions with limited concurrency 134 | - [p-memoize](https://github.com/sindresorhus/p-memoize) - Memoize promise-returning & async functions 135 | - [debounce-fn](https://github.com/sindresorhus/debounce-fn) - Debounce a function 136 | - [More…](https://github.com/sindresorhus/promise-fun) 137 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import {test} from 'node:test'; 2 | import {strict as assert} from 'node:assert'; 3 | import {setTimeout as delay} from 'node:timers/promises'; 4 | import pDebounce from './index.js'; 5 | 6 | const fixture = Symbol('fixture'); 7 | 8 | test('single call', async () => { 9 | const debounced = pDebounce(async value => value, 100); 10 | const result = await debounced(fixture); 11 | assert.equal(result, fixture); 12 | }); 13 | 14 | test('multiple calls', async () => { 15 | let count = 0; 16 | const start = Date.now(); 17 | 18 | const debounced = pDebounce(async value => { 19 | count++; 20 | await delay(50); 21 | return value; 22 | }, 100); 23 | 24 | const results = await Promise.all([1, 2, 3, 4, 5].map(value => debounced(value))); 25 | const elapsed = Date.now() - start; 26 | 27 | assert.deepEqual(results, [5, 5, 5, 5, 5]); 28 | assert.equal(count, 1); 29 | assert.ok(elapsed >= 130 && elapsed <= 170); 30 | 31 | await delay(200); 32 | assert.equal(await debounced(6), 6); 33 | }); 34 | 35 | test('.promise()', async () => { 36 | let count = 0; 37 | 38 | const debounced = pDebounce.promise(async () => { 39 | await delay(50); 40 | count++; 41 | return count; 42 | }); 43 | 44 | const results = await Promise.all([1, 2, 3, 4, 5].map(value => debounced(value))); 45 | assert.deepEqual(results, [1, 1, 1, 1, 1]); 46 | assert.equal(await debounced(), 2); 47 | }); 48 | 49 | test('.promise() - concurrent calls return same promise', async () => { 50 | let callCount = 0; 51 | const calls = []; 52 | 53 | const debounced = pDebounce.promise(async value => { 54 | callCount++; 55 | calls.push(value); 56 | await delay(50); 57 | return value; 58 | }); 59 | 60 | // Make multiple calls while first is executing 61 | const p1 = debounced('first'); 62 | const p2 = debounced('second'); 63 | await delay(10); // During execution 64 | const p3 = debounced('third'); 65 | 66 | const [r1, r2, r3] = await Promise.all([p1, p2, p3]); 67 | 68 | // All should get result of first execution 69 | assert.equal(r1, 'first'); 70 | assert.equal(r2, 'first'); 71 | assert.equal(r3, 'first'); 72 | 73 | // Wait to ensure no background execution 74 | await delay(60); 75 | 76 | // Should have been called only once with 'first' 77 | assert.equal(callCount, 1); 78 | assert.deepEqual(calls, ['first']); 79 | }); 80 | 81 | test('before option', async () => { 82 | let count = 0; 83 | 84 | const debounced = pDebounce(async value => { 85 | count++; 86 | await delay(50); 87 | return value; 88 | }, 100, {before: true}); 89 | 90 | const results = await Promise.all([1, 2, 3, 4].map(value => debounced(value))); 91 | 92 | assert.deepEqual(results, [1, 1, 1, 1]); 93 | assert.equal(count, 1); 94 | 95 | await delay(200); 96 | assert.equal(await debounced(5), 5); 97 | assert.equal(await debounced(6), 5); 98 | }); 99 | 100 | test('before option - does not call input function after timeout', async () => { 101 | let count = 0; 102 | 103 | const debounced = pDebounce(async () => { 104 | count++; 105 | }, 100, {before: true}); 106 | 107 | await delay(300); 108 | await debounced(); 109 | 110 | assert.equal(count, 1); 111 | }); 112 | 113 | test('fn takes longer than wait', async () => { 114 | let count = 0; 115 | 116 | const debounced = pDebounce(async value => { 117 | count++; 118 | await delay(200); 119 | return value; 120 | }, 100); 121 | 122 | const setOne = [1, 2, 3]; 123 | const setTwo = [4, 5, 6]; 124 | 125 | const promiseSetOne = setOne.map(value => debounced(value)); 126 | await delay(101); 127 | const promiseSetTwo = setTwo.map(value => debounced(value)); 128 | 129 | const results = await Promise.all([...promiseSetOne, ...promiseSetTwo]); 130 | 131 | assert.deepEqual(results, [3, 3, 3, 6, 6, 6]); 132 | assert.equal(count, 2); 133 | }); 134 | 135 | // Factory to create a separate class for each test 136 | const createFixtureClass = () => class { 137 | constructor() { 138 | this._foo = fixture; 139 | } 140 | 141 | foo() { 142 | return this._foo; 143 | } 144 | 145 | getThis() { 146 | return this; 147 | } 148 | }; 149 | 150 | test('`this` is preserved in pDebounce() fn', async () => { 151 | const FixtureClass = createFixtureClass(); 152 | FixtureClass.prototype.foo = pDebounce(FixtureClass.prototype.foo, 10); 153 | FixtureClass.prototype.getThis = pDebounce(FixtureClass.prototype.getThis, 10); 154 | 155 | const thisFixture = new FixtureClass(); 156 | 157 | assert.equal(await thisFixture.getThis(), thisFixture); 158 | assert.doesNotThrow(async () => thisFixture.foo()); 159 | assert.equal(await thisFixture.foo(), fixture); 160 | }); 161 | 162 | test('`this` is preserved in pDebounce.promise() fn', async () => { 163 | const FixtureClass = createFixtureClass(); 164 | FixtureClass.prototype.foo = pDebounce.promise(FixtureClass.prototype.foo, 10); 165 | FixtureClass.prototype.getThis = pDebounce.promise(FixtureClass.prototype.getThis, 10); 166 | 167 | const thisFixture = new FixtureClass(); 168 | 169 | assert.equal(await thisFixture.getThis(), thisFixture); 170 | assert.doesNotThrow(async () => thisFixture.foo()); 171 | assert.equal(await thisFixture.foo(), fixture); 172 | }); 173 | 174 | test('AbortSignal cancels debounced calls', async () => { 175 | let callCount = 0; 176 | const controller = new AbortController(); 177 | 178 | const debounced = pDebounce(async value => { 179 | callCount++; 180 | await delay(50); 181 | return value; 182 | }, 100, { 183 | signal: controller.signal, 184 | }); 185 | 186 | const promise = debounced(1); 187 | 188 | controller.abort(); 189 | 190 | await assert.rejects(promise, error => { 191 | assert.equal(error.name, 'AbortError'); 192 | return true; 193 | }); 194 | 195 | assert.equal(callCount, 0); 196 | }); 197 | 198 | test('already aborted signal prevents execution', async () => { 199 | const controller = new AbortController(); 200 | controller.abort(); 201 | 202 | const debounced = pDebounce(async value => value, 100, { 203 | signal: controller.signal, 204 | }); 205 | 206 | const promise = debounced(1); 207 | 208 | await assert.rejects(promise, error => { 209 | assert.equal(error.name, 'AbortError'); 210 | return true; 211 | }); 212 | }); 213 | 214 | test('AbortSignal works with before option', async () => { 215 | let callCount = 0; 216 | const controller = new AbortController(); 217 | 218 | const debounced = pDebounce(async value => { 219 | callCount++; 220 | await delay(50); 221 | return value; 222 | }, 100, {before: true, signal: controller.signal}); 223 | 224 | // First call executes immediately 225 | const promise1 = debounced(1); 226 | const result1 = await promise1; 227 | assert.equal(result1, 1); 228 | assert.equal(callCount, 1); 229 | 230 | // Second call is pending 231 | const promise2 = debounced(2); 232 | 233 | // Abort before timeout 234 | controller.abort(); 235 | 236 | await assert.rejects(promise2, error => { 237 | assert.equal(error.name, 'AbortError'); 238 | return true; 239 | }); 240 | 241 | // Call count should still be 1 (only first call executed) 242 | assert.equal(callCount, 1); 243 | }); 244 | 245 | test('multiple promises are cancelled together with AbortSignal', async () => { 246 | let callCount = 0; 247 | const controller = new AbortController(); 248 | 249 | const debounced = pDebounce(async value => { 250 | callCount++; 251 | return value; 252 | }, 100, { 253 | signal: controller.signal, 254 | }); 255 | 256 | const promise1 = debounced(1); 257 | const promise2 = debounced(2); 258 | const promise3 = debounced(3); 259 | 260 | controller.abort(); 261 | 262 | await assert.rejects(promise1, {name: 'AbortError'}); 263 | await assert.rejects(promise2, {name: 'AbortError'}); 264 | await assert.rejects(promise3, {name: 'AbortError'}); 265 | 266 | assert.equal(callCount, 0); 267 | }); 268 | 269 | test('function still works after AbortSignal cancellation', async () => { 270 | const controller1 = new AbortController(); 271 | const debounced = pDebounce(async value => value, 100, { 272 | signal: controller1.signal, 273 | }); 274 | 275 | // Cancel initial call 276 | const promise1 = debounced(1); 277 | controller1.abort(); 278 | await assert.rejects(promise1); 279 | 280 | // Should work normally with new signal after cancellation 281 | const controller2 = new AbortController(); 282 | const debounced2 = pDebounce(async value => value, 100, { 283 | signal: controller2.signal, 284 | }); 285 | const result = await debounced2(2); 286 | assert.equal(result, 2); 287 | }); 288 | 289 | test('abort listener is cleaned up after normal completion', async () => { 290 | const controller = new AbortController(); 291 | const {signal} = controller; 292 | 293 | // Track listener count 294 | const initialListenerCount = signal.eventNames?.()?.length ?? 0; 295 | 296 | const debounced = pDebounce(async value => value, 100, {signal}); 297 | 298 | // Call the function 299 | const promise = debounced(1); 300 | 301 | // Wait for completion 302 | const result = await promise; 303 | assert.equal(result, 1); 304 | 305 | // Give time for cleanup 306 | await delay(10); 307 | 308 | // Check that listeners are cleaned up 309 | const finalListenerCount = signal.eventNames?.()?.length ?? 0; 310 | assert.equal(finalListenerCount, initialListenerCount, 'Abort listener should be removed after completion'); 311 | }); 312 | 313 | test('multiple abort signals are handled correctly without leaks', async () => { 314 | // Test with multiple signals to ensure no leaks 315 | const promises = []; 316 | for (let index = 0; index < 5; index++) { 317 | const controller = new AbortController(); 318 | const debounced = pDebounce(async value => value, 50, { 319 | signal: controller.signal, 320 | }); 321 | 322 | promises.push(debounced(index)); 323 | } 324 | 325 | await Promise.all(promises); 326 | 327 | // If this test completes without memory issues, listeners are being cleaned up 328 | assert.ok(true); 329 | }); 330 | 331 | test('before option - all calls in window resolve to same leading value', async () => { 332 | let callCount = 0; 333 | 334 | const debounced = pDebounce(async value => { 335 | callCount++; 336 | return value; 337 | }, 100, {before: true}); 338 | 339 | // Multiple calls in quick succession 340 | const promises = [ 341 | debounced(1), 342 | debounced(2), 343 | debounced(3), 344 | debounced(4), 345 | ]; 346 | 347 | const results = await Promise.all(promises); 348 | 349 | // All should resolve to the first value 350 | assert.deepEqual(results, [1, 1, 1, 1], 'All calls should resolve to the first value'); 351 | assert.equal(callCount, 1, 'Function should only be called once'); 352 | }); 353 | 354 | test('abort rejects all pending callers with consistent AbortError', async () => { 355 | const controller = new AbortController(); 356 | 357 | const debounced = pDebounce(async value => { 358 | await delay(50); 359 | return value; 360 | }, 100, {signal: controller.signal}); 361 | 362 | // Queue multiple calls 363 | const promise1 = debounced(1); 364 | const promise2 = debounced(2); 365 | const promise3 = debounced(3); 366 | 367 | // Abort after a short delay 368 | await delay(10); 369 | controller.abort(); 370 | 371 | // All should reject with AbortError 372 | const results = await Promise.allSettled([promise1, promise2, promise3]); 373 | const errors = results 374 | .filter(result => result.status === 'rejected') 375 | .map(result => result.reason); 376 | 377 | // All errors should be AbortError 378 | assert.equal(errors.length, 3); 379 | for (const error of errors) { 380 | assert.equal(error.name, 'AbortError'); 381 | } 382 | }); 383 | 384 | test('this context is preserved in non-before mode', async () => { 385 | const object = { 386 | value: 42, 387 | async getValue() { 388 | return this.value; 389 | }, 390 | }; 391 | 392 | object.getValue = pDebounce(object.getValue, 50); 393 | 394 | const result = await object.getValue(); 395 | assert.equal(result, 42, 'this context should be preserved'); 396 | }); 397 | 398 | test('handles synchronous errors correctly', async () => { 399 | const errorMessage = 'Sync error'; 400 | const debounced = pDebounce(() => { 401 | throw new Error(errorMessage); 402 | }, 50); 403 | 404 | await assert.rejects(debounced(), error => { 405 | assert.equal(error.message, errorMessage); 406 | return true; 407 | }); 408 | }); 409 | 410 | test('handles synchronous errors with before option', async () => { 411 | const errorMessage = 'Sync error in before mode'; 412 | const debounced = pDebounce(() => { 413 | throw new Error(errorMessage); 414 | }, 50, {before: true}); 415 | 416 | await assert.rejects(debounced(), error => { 417 | assert.equal(error.message, errorMessage); 418 | return true; 419 | }); 420 | }); 421 | 422 | test('handles rejected promises correctly', async () => { 423 | const errorMessage = 'Async rejection'; 424 | const debounced = pDebounce(async () => { 425 | throw new Error(errorMessage); 426 | }, 50); 427 | 428 | await assert.rejects(debounced(), error => { 429 | assert.equal(error.message, errorMessage); 430 | return true; 431 | }); 432 | }); 433 | 434 | test('multiple callers all receive the same rejection', async () => { 435 | const errorMessage = 'Shared rejection'; 436 | const debounced = pDebounce(async () => { 437 | await delay(10); 438 | throw new Error(errorMessage); 439 | }, 50); 440 | 441 | const promises = [ 442 | debounced(1), 443 | debounced(2), 444 | debounced(3), 445 | ]; 446 | 447 | const results = await Promise.allSettled(promises); 448 | 449 | // All should be rejected with the same error 450 | assert.equal(results.length, 3); 451 | for (const result of results) { 452 | assert.equal(result.status, 'rejected'); 453 | assert.equal(result.reason.message, errorMessage); 454 | } 455 | }); 456 | 457 | test('handles undefined and null arguments', async () => { 458 | const debounced = pDebounce(async (...arguments_) => arguments_, 50); 459 | 460 | const result1 = await debounced(undefined); 461 | assert.deepEqual(result1, [undefined]); 462 | 463 | const result2 = await debounced(null); 464 | assert.deepEqual(result2, [null]); 465 | 466 | const result3 = await debounced(); 467 | assert.deepEqual(result3, []); 468 | }); 469 | 470 | test('rapid abort signals do not cause memory leaks', async () => { 471 | // Create and abort many signals in rapid succession 472 | const promises = []; 473 | 474 | for (let index = 0; index < 100; index++) { 475 | const controller = new AbortController(); 476 | const debounced = pDebounce(async value => value, 50, { 477 | signal: controller.signal, 478 | }); 479 | 480 | const promise = debounced(index); 481 | controller.abort(); 482 | // eslint-disable-next-line promise/prefer-await-to-then 483 | promises.push(promise.catch(() => {})); // Ignore rejections 484 | } 485 | 486 | await Promise.all(promises); 487 | assert.ok(true, 'Rapid aborts handled without issues'); 488 | }); 489 | 490 | test('before option with immediate abort', async () => { 491 | const controller = new AbortController(); 492 | 493 | const debounced = pDebounce(async value => { 494 | await delay(50); 495 | return value; 496 | }, 100, {before: true, signal: controller.signal}); 497 | 498 | const promise = debounced(1); 499 | controller.abort(); // Abort immediately after leading call 500 | 501 | // Leading call should still complete successfully 502 | const result = await promise; 503 | assert.equal(result, 1); 504 | }); 505 | 506 | test('mixed successful and failed calls', async () => { 507 | let shouldFail = false; 508 | const debounced = pDebounce(async value => { 509 | if (shouldFail) { 510 | throw new Error('Failed'); 511 | } 512 | 513 | return value; 514 | }, 50); 515 | 516 | // First batch succeeds 517 | const result1 = await debounced(1); 518 | assert.equal(result1, 1); 519 | 520 | // Second batch fails 521 | shouldFail = true; 522 | await assert.rejects(debounced(2), {message: 'Failed'}); 523 | 524 | // Third batch succeeds again 525 | shouldFail = false; 526 | const result3 = await debounced(3); 527 | assert.equal(result3, 3); 528 | }); 529 | 530 | test('error handling - documents behavior for issue #7', async () => { 531 | const errorMessage = 'Test error for issue #7'; 532 | let callCount = 0; 533 | 534 | const debounced = pDebounce(async () => { 535 | callCount++; 536 | await delay(10); 537 | throw new Error(errorMessage); 538 | }, 50); 539 | 540 | // Make multiple calls that should all be debounced together 541 | const promise1 = debounced(); 542 | const promise2 = debounced(); 543 | const promise3 = debounced(); 544 | 545 | // All promises should reject with the same error (issue #7 behavior) 546 | const results = await Promise.allSettled([promise1, promise2, promise3]); 547 | 548 | // Verify function was only called once due to debouncing 549 | assert.equal(callCount, 1); 550 | 551 | // Verify all calls were rejected with the same error 552 | for (const result of results) { 553 | assert.equal(result.status, 'rejected'); 554 | assert.equal(result.reason.message, errorMessage); 555 | } 556 | }); 557 | 558 | test('calls made during function execution resolve with correct values', async () => { 559 | let callCount = 0; 560 | 561 | const slowFunction = async value => { 562 | callCount++; 563 | await delay(100); 564 | return value; 565 | }; 566 | 567 | const debounced = pDebounce(slowFunction, 50); 568 | 569 | // First call starts the debounce 570 | const promise1 = debounced('first'); 571 | 572 | // Wait for debounce to trigger 573 | await delay(60); 574 | 575 | // Make calls while function is executing 576 | const promise2 = debounced('second'); 577 | const promise3 = debounced('third'); 578 | 579 | const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); 580 | 581 | // First call should resolve with its own result 582 | assert.equal(result1, 'first'); 583 | 584 | // Calls made during execution should resolve with the latest argument 585 | assert.equal(result2, 'third'); 586 | assert.equal(result3, 'third'); 587 | 588 | // Function should execute twice 589 | assert.equal(callCount, 2); 590 | }); 591 | 592 | test('concurrent debounced functions do not interfere', async () => { 593 | const calls1 = []; 594 | const calls2 = []; 595 | 596 | const debounced1 = pDebounce(async value => { 597 | calls1.push(value); 598 | await delay(50); 599 | return `fn1-${value}`; 600 | }, 30); 601 | 602 | const debounced2 = pDebounce(async value => { 603 | calls2.push(value); 604 | await delay(50); 605 | return `fn2-${value}`; 606 | }, 30); 607 | 608 | const [r1, r2, r3, r4] = await Promise.all([ 609 | debounced1('a'), 610 | debounced2('x'), 611 | debounced1('b'), 612 | debounced2('y'), 613 | ]); 614 | 615 | assert.equal(r1, 'fn1-b'); 616 | assert.equal(r3, 'fn1-b'); 617 | assert.equal(r2, 'fn2-y'); 618 | assert.equal(r4, 'fn2-y'); 619 | assert.deepEqual(calls1, ['b']); 620 | assert.deepEqual(calls2, ['y']); 621 | }); 622 | 623 | test('extremely short wait time (0ms)', async () => { 624 | let callCount = 0; 625 | const debounced = pDebounce(async value => { 626 | callCount++; 627 | return value; 628 | }, 0); 629 | 630 | const p1 = debounced(1); 631 | const p2 = debounced(2); 632 | const p3 = debounced(3); 633 | 634 | const results = await Promise.all([p1, p2, p3]); 635 | 636 | assert.deepEqual(results, [3, 3, 3]); 637 | assert.equal(callCount, 1); 638 | }); 639 | 640 | test('before option with synchronous function', async () => { 641 | const calls = []; 642 | const debounced = pDebounce(value => { 643 | calls.push(value); 644 | return value * 2; 645 | }, 50, {before: true}); 646 | 647 | const results = await Promise.all([ 648 | debounced(1), 649 | debounced(2), 650 | debounced(3), 651 | ]); 652 | 653 | assert.deepEqual(results, [2, 2, 2]); 654 | assert.deepEqual(calls, [1]); 655 | }); 656 | 657 | test('abort signal during different phases', async () => { 658 | const controller1 = new AbortController(); 659 | const controller2 = new AbortController(); 660 | const controller3 = new AbortController(); 661 | 662 | const fn = async value => { 663 | await delay(100); 664 | return value; 665 | }; 666 | 667 | const debounced1 = pDebounce(fn, 50, {signal: controller1.signal}); 668 | const debounced2 = pDebounce(fn, 50, {signal: controller2.signal}); 669 | const debounced3 = pDebounce(fn, 50, {signal: controller3.signal}); 670 | 671 | // Abort before any call 672 | controller1.abort(); 673 | await assert.rejects(debounced1(1), {name: 'AbortError'}); 674 | 675 | // Abort during wait period 676 | const p2 = debounced2(2); 677 | await delay(25); 678 | controller2.abort(); 679 | await assert.rejects(p2, {name: 'AbortError'}); 680 | 681 | // Abort during execution 682 | const p3 = debounced3(3); 683 | await delay(60); // Let it start executing 684 | controller3.abort(); 685 | const result = await p3; // Should still complete 686 | assert.equal(result, 3); 687 | }); 688 | 689 | test('error propagation to all waiting promises', async () => { 690 | const error = new Error('Test error'); 691 | let shouldThrow = true; 692 | 693 | const debounced = pDebounce(async value => { 694 | if (shouldThrow) { 695 | throw error; 696 | } 697 | 698 | return value; 699 | }, 50); 700 | 701 | const promises = Array.from({length: 10}, (_, i) => debounced(i)); 702 | 703 | const results = await Promise.allSettled(promises); 704 | 705 | // All should be rejected with the same error 706 | for (const result of results) { 707 | assert.equal(result.status, 'rejected'); 708 | assert.equal(result.reason, error); 709 | } 710 | 711 | // Subsequent calls should work 712 | shouldThrow = false; 713 | const result = await debounced(42); 714 | assert.equal(result, 42); 715 | }); 716 | 717 | test('wait parameter validation', async () => { 718 | assert.throws(() => pDebounce(() => {}, Number.NaN), TypeError); 719 | assert.throws(() => pDebounce(() => {}, Number.POSITIVE_INFINITY), TypeError); 720 | assert.throws(() => pDebounce(() => {}, Number.NEGATIVE_INFINITY), TypeError); 721 | assert.throws(() => pDebounce(() => {}, 'not a number'), TypeError); 722 | 723 | // These should work 724 | assert.doesNotThrow(() => pDebounce(() => {}, 0)); 725 | assert.doesNotThrow(() => pDebounce(() => {}, -100)); // Negative waits work like 0 726 | assert.doesNotThrow(() => pDebounce(() => {}, 1.5)); // Decimals are fine 727 | }); 728 | 729 | test('before option with errors in leading call', async () => { 730 | const error = new Error('Leading error'); 731 | let callCount = 0; 732 | const debounced = pDebounce(() => { 733 | callCount++; 734 | throw error; 735 | }, 50, {before: true}); 736 | 737 | await assert.rejects(debounced(1), error); 738 | 739 | // Only called once due to before option 740 | assert.equal(callCount, 1); 741 | }); 742 | 743 | test('complex argument passing', async () => { 744 | const debounced = pDebounce(async (...arguments_) => arguments_, 50); 745 | 746 | const object = {foo: 'bar'}; 747 | const array = [1, 2, 3]; 748 | const fn = () => {}; 749 | 750 | const result = await debounced(object, array, fn, undefined, null, 42); 751 | 752 | assert.deepEqual(result[0], object); 753 | assert.deepEqual(result[1], array); 754 | assert.equal(result[2], fn); 755 | assert.equal(result[3], undefined); 756 | assert.equal(result[4], null); 757 | assert.equal(result[5], 42); 758 | }); 759 | 760 | test('maintains proper promise resolution order', async () => { 761 | const executionOrder = []; 762 | let executionCount = 0; 763 | 764 | const debounced = pDebounce(async value => { 765 | executionCount++; 766 | const currentExecution = executionCount; 767 | await delay(value === 'slow' ? 150 : 50); 768 | executionOrder.push({value, execution: currentExecution}); 769 | return value; 770 | }, 30); 771 | 772 | // First call - slow execution 773 | const p1 = debounced('slow'); 774 | await delay(35); // Let it start 775 | 776 | // Calls during execution 777 | const p2 = debounced('fast1'); 778 | const p3 = debounced('fast2'); 779 | 780 | const [r1, r2, r3] = await Promise.all([p1, p2, p3]); 781 | 782 | // First call gets its own result 783 | assert.equal(r1, 'slow'); 784 | 785 | // Calls during execution get the latest argument 786 | assert.equal(r2, 'fast2'); 787 | assert.equal(r3, 'fast2'); 788 | 789 | // Should have executed twice 790 | assert.equal(executionCount, 2); 791 | }); 792 | 793 | test('debounce with negative wait time', async () => { 794 | let callCount = 0; 795 | const debounced = pDebounce(async value => { 796 | callCount++; 797 | return value; 798 | }, -50); // Negative wait should work like 0 799 | 800 | const results = await Promise.all([ 801 | debounced(1), 802 | debounced(2), 803 | debounced(3), 804 | ]); 805 | 806 | assert.deepEqual(results, [3, 3, 3]); 807 | assert.equal(callCount, 1); 808 | }); 809 | 810 | test('abort signal removed after successful execution', async () => { 811 | const controller = new AbortController(); 812 | const {signal} = controller; 813 | 814 | const debounced = pDebounce(async value => { 815 | await delay(50); 816 | return value; 817 | }, 100, {signal}); 818 | 819 | // Make a call and let it complete 820 | const result = await debounced('test'); 821 | assert.equal(result, 'test'); 822 | 823 | // Abort after completion shouldn't affect new calls 824 | controller.abort(); 825 | 826 | // New call with new controller should work 827 | const controller2 = new AbortController(); 828 | const debounced2 = pDebounce(async value => value, 50, {signal: controller2.signal}); 829 | const result2 = await debounced2('test2'); 830 | assert.equal(result2, 'test2'); 831 | }); 832 | 833 | test('multiple debounced functions with before option', async () => { 834 | const calls = []; 835 | 836 | const d1 = pDebounce(v => { 837 | calls.push(`d1-${v}`); 838 | return `d1-${v}`; 839 | }, 50, {before: true}); 840 | 841 | const d2 = pDebounce(v => { 842 | calls.push(`d2-${v}`); 843 | return `d2-${v}`; 844 | }, 50, {before: true}); 845 | 846 | // Call both in quick succession 847 | const [r1, r2, r3, r4] = await Promise.all([ 848 | d1('a'), 849 | d2('x'), 850 | d1('b'), 851 | d2('y'), 852 | ]); 853 | 854 | assert.equal(r1, 'd1-a'); 855 | assert.equal(r2, 'd2-x'); 856 | assert.equal(r3, 'd1-a'); // Same as r1 due to before option 857 | assert.equal(r4, 'd2-x'); // Same as r2 due to before option 858 | assert.deepEqual(calls, ['d1-a', 'd2-x']); 859 | }); 860 | 861 | test('debounce function returning undefined', async () => { 862 | let callCount = 0; 863 | const debounced = pDebounce(() => { 864 | callCount++; 865 | // Implicitly returns undefined 866 | }, 50); 867 | 868 | const results = await Promise.all([ 869 | debounced(), 870 | debounced(), 871 | debounced(), 872 | ]); 873 | 874 | assert.equal(callCount, 1); 875 | assert.deepEqual(results, [undefined, undefined, undefined]); 876 | }); 877 | 878 | test('edge case: call during abort signal cleanup', async () => { 879 | const controller = new AbortController(); 880 | let callCount = 0; 881 | 882 | const debounced = pDebounce(async value => { 883 | callCount++; 884 | await delay(100); 885 | return value; 886 | }, 50, {signal: controller.signal}); 887 | 888 | const p1 = debounced(1); 889 | 890 | // Wait for function to start 891 | await delay(60); 892 | 893 | // Abort while function is executing 894 | controller.abort(); 895 | 896 | // Make new call immediately - should be rejected because signal is aborted 897 | // eslint-disable-next-line promise/prefer-await-to-then 898 | const p2 = debounced(2).catch(() => {}); // Handle the rejection 899 | 900 | // First call should complete despite abort happening during execution 901 | const result1 = await p1; 902 | assert.equal(result1, 1); 903 | 904 | // Second call should have been rejected (handled above) 905 | await p2; 906 | assert.equal(callCount, 1); 907 | }); 908 | 909 | test('callstack is preserved in debounced function', async () => { 910 | let capturedStack; 911 | 912 | const debounced = pDebounce(async () => { 913 | capturedStack = new Error('Stack trace').stack; 914 | return 'done'; 915 | }, 50); 916 | 917 | async function namedFunction() { 918 | return debounced(); 919 | } 920 | 921 | await namedFunction(); 922 | 923 | // Stack trace should be captured 924 | assert.ok(capturedStack, 'Stack trace should be captured'); 925 | assert.ok(capturedStack.includes('Error'), 'Stack trace should include Error'); 926 | }); 927 | 928 | test('zero timeout executes in next tick', async () => { 929 | const executionOrder = []; 930 | 931 | const debounced = pDebounce(async () => { 932 | executionOrder.push('debounced'); 933 | }, 0); 934 | 935 | debounced(); 936 | executionOrder.push('sync'); 937 | 938 | await delay(1); 939 | 940 | assert.deepEqual(executionOrder, ['sync', 'debounced']); 941 | }); 942 | 943 | test('debounce with AbortSignal.timeout', async () => { 944 | // Skip if AbortSignal.timeout is not available (Node 16+) 945 | if (typeof AbortSignal.timeout !== 'function') { 946 | return; 947 | } 948 | 949 | const debounced = pDebounce(async value => { 950 | await delay(200); 951 | return value; 952 | }, 150, {signal: AbortSignal.timeout(100)}); 953 | 954 | const promise = debounced('test'); 955 | 956 | // Should abort after 100ms timeout (during the debounce wait period) 957 | // Different Node versions may use different error names 958 | await assert.rejects(promise, error => error.name === 'TimeoutError' || error.name === 'AbortError'); 959 | }); 960 | 961 | test('before option with async error in leading call', async () => { 962 | const error = new Error('Async leading error'); 963 | const debounced = pDebounce(async () => { 964 | await delay(10); 965 | throw error; 966 | }, 50, {before: true}); 967 | 968 | const p1 = debounced(1); 969 | const p2 = debounced(2); 970 | 971 | // First call executes immediately and should reject 972 | await assert.rejects(p1, error); 973 | 974 | // Second call waits for timeout and resolves with cached leadingValue (undefined since first call errored) 975 | const result2 = await p2; 976 | assert.equal(result2, undefined); 977 | }); 978 | 979 | test('promise resolution with null and undefined', async () => { 980 | const debounced = pDebounce(async value => value, 50); 981 | 982 | const [r1, r2, r3] = await Promise.all([ 983 | debounced(null), 984 | debounced(undefined), 985 | debounced(), 986 | ]); 987 | 988 | assert.equal(r1, undefined); // Last call had no arguments 989 | assert.equal(r2, undefined); 990 | assert.equal(r3, undefined); 991 | }); 992 | 993 | test('multiple abort controllers on same debounced function', async () => { 994 | const controller1 = new AbortController(); 995 | const controller2 = new AbortController(); 996 | 997 | const fn = async value => { 998 | await delay(100); 999 | return value; 1000 | }; 1001 | 1002 | const debounced1 = pDebounce(fn, 50, {signal: controller1.signal}); 1003 | const debounced2 = pDebounce(fn, 50, {signal: controller2.signal}); 1004 | 1005 | const p1 = debounced1(1); 1006 | const p2 = debounced2(2); 1007 | 1008 | // Abort only the first one 1009 | controller1.abort(); 1010 | 1011 | await assert.rejects(p1, {name: 'AbortError'}); 1012 | 1013 | // Second should complete normally 1014 | const result2 = await p2; 1015 | assert.equal(result2, 2); 1016 | }); 1017 | 1018 | test('debounce preserves promise rejection details', async () => { 1019 | const customError = new TypeError('Custom type error'); 1020 | customError.code = 'CUSTOM_CODE'; 1021 | customError.details = {foo: 'bar'}; 1022 | 1023 | const debounced = pDebounce(async () => { 1024 | throw customError; 1025 | }, 50); 1026 | 1027 | try { 1028 | await debounced(); 1029 | assert.fail('Should have thrown'); 1030 | } catch (error) { 1031 | assert.equal(error, customError); 1032 | assert.equal(error.code, 'CUSTOM_CODE'); 1033 | assert.deepEqual(error.details, {foo: 'bar'}); 1034 | } 1035 | }); 1036 | 1037 | test('simultaneous before and non-before debounced functions', async () => { 1038 | const calls = []; 1039 | 1040 | const debouncedBefore = pDebounce(value => { 1041 | calls.push(`before-${value}`); 1042 | return `before-${value}`; 1043 | }, 50, {before: true}); 1044 | 1045 | const debouncedAfter = pDebounce(value => { 1046 | calls.push(`after-${value}`); 1047 | return `after-${value}`; 1048 | }, 50); 1049 | 1050 | const [r1, r2, r3, r4] = await Promise.all([ 1051 | debouncedBefore('a'), 1052 | debouncedBefore('b'), 1053 | debouncedAfter('x'), 1054 | debouncedAfter('y'), 1055 | ]); 1056 | 1057 | assert.equal(r1, 'before-a'); 1058 | assert.equal(r2, 'before-a'); 1059 | assert.equal(r3, 'after-y'); 1060 | assert.equal(r4, 'after-y'); 1061 | assert.deepEqual(calls, ['before-a', 'after-y']); 1062 | }); 1063 | 1064 | test('nested debounced functions', async () => { 1065 | const innerCalls = []; 1066 | const outerCalls = []; 1067 | 1068 | const innerDebounced = pDebounce(async value => { 1069 | innerCalls.push(value); 1070 | return `inner-${value}`; 1071 | }, 30); 1072 | 1073 | const outerDebounced = pDebounce(async value => { 1074 | outerCalls.push(value); 1075 | const innerResult = await innerDebounced(value); 1076 | return `outer(${innerResult})`; 1077 | }, 50); 1078 | 1079 | const result = await outerDebounced('test'); 1080 | 1081 | assert.equal(result, 'outer(inner-test)'); 1082 | assert.deepEqual(outerCalls, ['test']); 1083 | assert.deepEqual(innerCalls, ['test']); 1084 | }); 1085 | 1086 | test('race condition: new call right at timeout boundary', async () => { 1087 | let callCount = 0; 1088 | const results = []; 1089 | 1090 | const debounced = pDebounce(async value => { 1091 | callCount++; 1092 | results.push(value); 1093 | return value; 1094 | }, 50); 1095 | 1096 | const p1 = debounced(1); 1097 | 1098 | // Wait exactly the debounce time 1099 | await delay(50); 1100 | 1101 | // Call right when timeout fires 1102 | const p2 = debounced(2); 1103 | 1104 | const [r1, r2] = await Promise.all([p1, p2]); 1105 | 1106 | // Should have been called twice 1107 | assert.equal(callCount, 2); 1108 | assert.deepEqual(results, [1, 2]); 1109 | assert.equal(r1, 1); 1110 | assert.equal(r2, 2); 1111 | }); 1112 | 1113 | test('function with Symbol return value', async () => { 1114 | const sym = Symbol('test'); 1115 | const debounced = pDebounce(async () => sym, 50); 1116 | 1117 | const result = await debounced(); 1118 | assert.equal(result, sym); 1119 | }); 1120 | 1121 | test('concurrent calls with different this contexts', async () => { 1122 | const contexts = []; 1123 | 1124 | const debounced = pDebounce(async function (value) { 1125 | contexts.push(this); 1126 | return value; 1127 | }, 50); 1128 | 1129 | const object1 = {name: 'object1', fn: debounced}; 1130 | const object2 = {name: 'object2', fn: debounced}; 1131 | 1132 | const [r1, r2] = await Promise.all([ 1133 | object1.fn('a'), 1134 | object2.fn('b'), 1135 | ]); 1136 | 1137 | // Both should use the last context 1138 | assert.equal(contexts.length, 1); 1139 | assert.equal(contexts[0], object2); 1140 | assert.equal(r1, 'b'); 1141 | assert.equal(r2, 'b'); 1142 | }); 1143 | 1144 | test('context preservation in pDebounce', async () => { 1145 | const results = []; 1146 | 1147 | class TestClass { 1148 | constructor(name) { 1149 | this.name = name; 1150 | } 1151 | 1152 | async method(value) { 1153 | results.push({name: this.name, value}); 1154 | return `${this.name}-${value}`; 1155 | } 1156 | } 1157 | 1158 | const instance = new TestClass('test'); 1159 | instance.debouncedMethod = pDebounce(instance.method, 50); 1160 | 1161 | const result = await instance.debouncedMethod('hello'); 1162 | 1163 | assert.equal(result, 'test-hello'); 1164 | assert.equal(results.length, 1); 1165 | assert.equal(results[0].name, 'test'); 1166 | assert.equal(results[0].value, 'hello'); 1167 | }); 1168 | 1169 | test('context preservation in pDebounce.promise', async () => { 1170 | const results = []; 1171 | 1172 | class TestClass { 1173 | constructor(name) { 1174 | this.name = name; 1175 | } 1176 | 1177 | async method(value) { 1178 | results.push({name: this.name, value}); 1179 | return `${this.name}-${value}`; 1180 | } 1181 | } 1182 | 1183 | const instance = new TestClass('promise-test'); 1184 | instance.debouncedMethod = pDebounce.promise(instance.method); 1185 | 1186 | const result = await instance.debouncedMethod('world'); 1187 | 1188 | assert.equal(result, 'promise-test-world'); 1189 | assert.equal(results.length, 1); 1190 | assert.equal(results[0].name, 'promise-test'); 1191 | assert.equal(results[0].value, 'world'); 1192 | }); 1193 | 1194 | test('arguments preservation in complex scenarios', async () => { 1195 | const calls = []; 1196 | 1197 | const fn = pDebounce(async (...arguments_) => { 1198 | calls.push(arguments_); 1199 | return arguments_; 1200 | }, 50); 1201 | 1202 | const complexObject = {deep: {nested: 'value'}}; 1203 | const array = [1, 2, 3]; 1204 | const func = () => 'function'; 1205 | 1206 | const result = await fn(complexObject, array, func, null, undefined, 42); 1207 | 1208 | assert.equal(calls.length, 1); 1209 | assert.deepEqual(calls[0][0], complexObject); 1210 | assert.deepEqual(calls[0][1], array); 1211 | assert.equal(calls[0][2], func); 1212 | assert.equal(calls[0][3], null); 1213 | assert.equal(calls[0][4], undefined); 1214 | assert.equal(calls[0][5], 42); 1215 | assert.deepEqual(result, [complexObject, array, func, null, undefined, 42]); 1216 | }); 1217 | 1218 | test('abort signal during various execution phases', async () => { 1219 | const controller1 = new AbortController(); 1220 | const controller2 = new AbortController(); 1221 | const controller3 = new AbortController(); 1222 | 1223 | let executionStarted = false; 1224 | 1225 | const fn = async value => { 1226 | executionStarted = true; 1227 | await delay(100); 1228 | return value; 1229 | }; 1230 | 1231 | // Test 1: Abort before any calls 1232 | const debounced1 = pDebounce(fn, 50, {signal: controller1.signal}); 1233 | controller1.abort(); 1234 | await assert.rejects(debounced1(1), {name: 'AbortError'}); 1235 | 1236 | // Test 2: Abort during wait period 1237 | const debounced2 = pDebounce(fn, 200, {signal: controller2.signal}); 1238 | const p2 = debounced2(2); 1239 | await delay(100); // Wait during debounce period 1240 | controller2.abort(); 1241 | await assert.rejects(p2, {name: 'AbortError'}); 1242 | 1243 | // Test 3: Abort during function execution (should complete) 1244 | executionStarted = false; 1245 | const debounced3 = pDebounce(fn, 50, {signal: controller3.signal}); 1246 | const p3 = debounced3(3); 1247 | await delay(60); // Wait for function to start 1248 | assert.ok(executionStarted, 'Function should have started'); 1249 | controller3.abort(); 1250 | const result3 = await p3; // Should complete despite abort 1251 | assert.equal(result3, 3); 1252 | }); 1253 | 1254 | test('memory cleanup after abort', async () => { 1255 | const controller = new AbortController(); 1256 | let calls = 0; 1257 | 1258 | const debounced = pDebounce(async () => { 1259 | calls++; 1260 | return 'result'; 1261 | }, 50, {signal: controller.signal}); 1262 | 1263 | // Make multiple calls 1264 | const promises = []; 1265 | for (let index = 0; index < 5; index++) { 1266 | const promise = debounced(index); 1267 | // eslint-disable-next-line promise/prefer-await-to-then 1268 | promises.push(promise.catch(() => {})); // Ignore rejections 1269 | } 1270 | 1271 | // Abort all 1272 | controller.abort(); 1273 | await Promise.all(promises); 1274 | 1275 | // Wait to ensure no delayed execution 1276 | await delay(100); 1277 | 1278 | // Function should never have been called 1279 | assert.equal(calls, 0); 1280 | 1281 | // New calls with same debounced function should still be rejected 1282 | await assert.rejects(debounced('new'), {name: 'AbortError'}); 1283 | }); 1284 | 1285 | test('.promise() with after option - basic behavior', async () => { 1286 | let callCount = 0; 1287 | const fn = async value => { 1288 | callCount++; 1289 | await delay(100); 1290 | return `${value}-${callCount}`; 1291 | }; 1292 | 1293 | const debounced = pDebounce.promise(fn, {after: true}); 1294 | 1295 | const promise1 = debounced('first'); 1296 | const promise2 = debounced('second'); 1297 | 1298 | const result1 = await promise1; 1299 | const result2 = await promise2; 1300 | 1301 | assert.equal(result1, 'first-1'); 1302 | assert.equal(result2, 'second-2'); 1303 | assert.equal(callCount, 2); 1304 | }); 1305 | 1306 | test('.promise() with after option - latest arguments win', async () => { 1307 | const results = []; 1308 | const fn = async value => { 1309 | results.push(value); 1310 | await delay(50); 1311 | return value; 1312 | }; 1313 | 1314 | const debounced = pDebounce.promise(fn, {after: true}); 1315 | 1316 | debounced('call1'); 1317 | debounced('call2'); 1318 | debounced('call3'); // Only this should execute after first completes 1319 | 1320 | await delay(200); 1321 | 1322 | assert.deepEqual(results, ['call1', 'call3']); 1323 | }); 1324 | 1325 | test('.promise() with after option - context preservation', async () => { 1326 | const object = { 1327 | value: 'test', 1328 | async method(suffix) { 1329 | await delay(50); 1330 | return `${this.value}-${suffix}`; 1331 | }, 1332 | }; 1333 | 1334 | const debounced = pDebounce.promise(object.method, {after: true}); 1335 | 1336 | const promise1 = debounced.call(object, 'first'); 1337 | const promise2 = debounced.call(object, 'second'); 1338 | 1339 | assert.equal(await promise1, 'test-first'); 1340 | assert.equal(await promise2, 'test-second'); 1341 | }); 1342 | 1343 | test('.promise() with after option - error handling', async () => { 1344 | let callCount = 0; 1345 | const fn = async value => { 1346 | callCount++; 1347 | await delay(50); 1348 | if (value === 'error') { 1349 | throw new Error('Test error'); 1350 | } 1351 | 1352 | return value; 1353 | }; 1354 | 1355 | const debounced = pDebounce.promise(fn, {after: true}); 1356 | 1357 | const promise1 = debounced('success'); 1358 | const promise2 = debounced('error'); 1359 | 1360 | assert.equal(await promise1, 'success'); 1361 | await assert.rejects(promise2, {message: 'Test error'}); 1362 | assert.equal(callCount, 2); 1363 | }); 1364 | 1365 | test('.promise() with after option - no queue when no overlapping calls', async () => { 1366 | let callCount = 0; 1367 | const fn = async value => { 1368 | callCount++; 1369 | await delay(50); 1370 | return value; 1371 | }; 1372 | 1373 | const debounced = pDebounce.promise(fn, {after: true}); 1374 | 1375 | const result1 = await debounced('first'); 1376 | const result2 = await debounced('second'); 1377 | 1378 | assert.equal(result1, 'first'); 1379 | assert.equal(result2, 'second'); 1380 | assert.equal(callCount, 2); 1381 | }); 1382 | 1383 | test('.promise() without after option - original behavior unchanged', async () => { 1384 | let callCount = 0; 1385 | const fn = async value => { 1386 | callCount++; 1387 | await delay(100); 1388 | return `${value}-${callCount}`; 1389 | }; 1390 | 1391 | const debounced = pDebounce.promise(fn); 1392 | 1393 | const promise1 = debounced('first'); 1394 | const promise2 = debounced('second'); 1395 | 1396 | const result1 = await promise1; 1397 | const result2 = await promise2; 1398 | 1399 | assert.equal(result1, 'first-1'); 1400 | assert.equal(result2, 'first-1'); // Same result, same promise 1401 | assert.equal(callCount, 1); 1402 | }); 1403 | 1404 | test('.promise() with after option - call during queued execution should resolve', async () => { 1405 | let executionCount = 0; 1406 | const executionOrder = []; 1407 | const fn = async value => { 1408 | executionCount++; 1409 | const id = executionCount; 1410 | executionOrder.push({value, id, phase: 'start'}); 1411 | await delay(50); 1412 | executionOrder.push({value, id, phase: 'end'}); 1413 | return `${value}-${id}`; 1414 | }; 1415 | 1416 | const debounced = pDebounce.promise(fn, {after: true}); 1417 | 1418 | // First call starts executing immediately 1419 | const promise1 = debounced('first'); 1420 | 1421 | // Second call during first execution - gets queued 1422 | const promise2 = debounced('second'); 1423 | 1424 | await delay(60); 1425 | 1426 | const promise3 = debounced('third'); 1427 | 1428 | const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); 1429 | 1430 | assert.equal(result1, 'first-1'); 1431 | assert.equal(result2, 'second-2'); 1432 | assert.equal(result3, 'third-3'); 1433 | }); 1434 | 1435 | test('.promise() with after option - multiple simultaneous queuers get same result', async () => { 1436 | const fn = async value => { 1437 | await delay(50); 1438 | return value; 1439 | }; 1440 | 1441 | const debounced = pDebounce.promise(fn, {after: true}); 1442 | 1443 | const promise1 = debounced('first'); 1444 | const promise2 = debounced('second'); 1445 | const promise3 = debounced('third'); 1446 | const promise4 = debounced('fourth'); 1447 | 1448 | const [result1, result2, result3, result4] = await Promise.all([promise1, promise2, promise3, promise4]); 1449 | 1450 | assert.equal(result1, 'first'); 1451 | // All queued calls should use latest arguments 1452 | assert.equal(result2, 'fourth'); 1453 | assert.equal(result3, 'fourth'); 1454 | assert.equal(result4, 'fourth'); 1455 | }); 1456 | 1457 | test('.promise() with after option - error in queued call followed by success', async () => { 1458 | const fn = async value => { 1459 | await delay(30); 1460 | if (value === 'error') { 1461 | throw new Error('Queued error'); 1462 | } 1463 | 1464 | return value; 1465 | }; 1466 | 1467 | const debounced = pDebounce.promise(fn, {after: true}); 1468 | 1469 | const promise1 = debounced('first'); 1470 | const promise2 = debounced('error'); 1471 | 1472 | assert.equal(await promise1, 'first'); 1473 | await assert.rejects(promise2, {message: 'Queued error'}); 1474 | 1475 | // Next call should still work after error 1476 | const promise3 = debounced('success'); 1477 | assert.equal(await promise3, 'success'); 1478 | }); 1479 | 1480 | test('.promise() with after option - error in initial call should still process queue', async () => { 1481 | const fn = async value => { 1482 | await delay(30); 1483 | if (value === 'error') { 1484 | throw new Error('Initial error'); 1485 | } 1486 | 1487 | return value; 1488 | }; 1489 | 1490 | const debounced = pDebounce.promise(fn, {after: true}); 1491 | 1492 | const promise1 = debounced('error'); 1493 | const promise2 = debounced('success'); 1494 | 1495 | await assert.rejects(promise1, {message: 'Initial error'}); 1496 | assert.equal(await promise2, 'success'); 1497 | }); 1498 | --------------------------------------------------------------------------------