1
0
mirror of https://github.com/robonen/tools.git synced 2026-03-20 10:54:44 +00:00

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
4d9bd6fea1 feat(vue): use cancellablePromise in useAsyncState for promise cancellation
Co-authored-by: robonen <26167508+robonen@users.noreply.github.com>
2026-02-26 15:34:49 +00:00
copilot-swe-agent[bot]
6b2707e24a fix(stdlib): improve cancellablePromise test to actually verify then callback is not called
Co-authored-by: robonen <26167508+robonen@users.noreply.github.com>
2026-02-26 15:01:56 +00:00
copilot-swe-agent[bot]
da17d2d068 feat(stdlib): add cancellablePromise utility for promise cancellation
Co-authored-by: robonen <26167508+robonen@users.noreply.github.com>
2026-02-26 15:01:02 +00:00
copilot-swe-agent[bot]
d9e9ee4e7f feat(useAsyncState): add abort method for force cancelling pending promises
Co-authored-by: robonen <26167508+robonen@users.noreply.github.com>
2026-02-26 14:44:02 +00:00
copilot-swe-agent[bot]
0b64e91eba Initial plan 2026-02-26 14:39:59 +00:00
7 changed files with 359 additions and 100 deletions

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi } from 'vitest';
import { cancellablePromise, CancelledError } from '.';
describe('cancellablePromise', () => {
it('resolve the promise normally when not cancelled', async () => {
const { promise } = cancellablePromise(Promise.resolve('data'));
await expect(promise).resolves.toBe('data');
});
it('reject the promise normally when not cancelled', async () => {
const error = new Error('test-error');
const { promise } = cancellablePromise(Promise.reject(error));
await expect(promise).rejects.toThrow(error);
});
it('reject with CancelledError when cancelled before resolve', async () => {
const { promise, cancel } = cancellablePromise(
new Promise<string>((resolve) => setTimeout(() => resolve('data'), 100)),
);
cancel();
await expect(promise).rejects.toBeInstanceOf(CancelledError);
await expect(promise).rejects.toThrow('Promise was cancelled');
});
it('reject with CancelledError with custom reason', async () => {
const { promise, cancel } = cancellablePromise(
new Promise<string>((resolve) => setTimeout(() => resolve('data'), 100)),
);
cancel('Request aborted');
await expect(promise).rejects.toBeInstanceOf(CancelledError);
await expect(promise).rejects.toThrow('Request aborted');
});
it('cancel prevents then callback from being called', async () => {
const onFulfilled = vi.fn();
const { promise, cancel } = cancellablePromise(
new Promise<string>((resolve) => setTimeout(() => resolve('data'), 100)),
);
const chained = promise.then(onFulfilled).catch(() => {});
cancel();
await chained;
expect(onFulfilled).not.toHaveBeenCalled();
});
it('CancelledError has correct name property', () => {
const error = new CancelledError();
expect(error.name).toBe('CancelledError');
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('Promise was cancelled');
});
it('CancelledError accepts custom message', () => {
const error = new CancelledError('Custom reason');
expect(error.message).toBe('Custom reason');
});
});

View File

@@ -0,0 +1,49 @@
export class CancelledError extends Error {
constructor(reason?: string) {
super(reason ?? 'Promise was cancelled');
this.name = 'CancelledError';
}
}
export interface CancellablePromise<T> {
promise: Promise<T>;
cancel: (reason?: string) => void;
}
/**
* @name cancellablePromise
* @category Async
* @description Wraps a promise with a cancel capability, allowing the promise to be rejected with a CancelledError
*
* @param {Promise<T>} promise - The promise to make cancellable
* @returns {CancellablePromise<T>} - An object with the wrapped promise and a cancel function
*
* @example
* const { promise, cancel } = cancellablePromise(fetch('/api/data'));
* cancel(); // Rejects with CancelledError
*
* @example
* const { promise, cancel } = cancellablePromise(longRunningTask());
* setTimeout(() => cancel('Timeout'), 5000);
* const [error] = await tryIt(() => promise)();
*
* @since 0.0.10
*/
export function cancellablePromise<T>(promise: Promise<T>): CancellablePromise<T> {
let rejectPromise: (reason: CancelledError) => void;
const wrappedPromise = new Promise<T>((resolve, reject) => {
rejectPromise = reject;
promise.then(resolve, reject);
});
const cancel = (reason?: string) => {
rejectPromise(new CancelledError(reason));
};
return {
promise: wrappedPromise,
cancel,
};
}

View File

@@ -1,2 +1,3 @@
export * from './cancellablePromise';
export * from './sleep';
export * from './tryIt';

183
pnpm-lock.yaml generated
View File

@@ -16,8 +16,8 @@ catalogs:
specifier: ^2.4.6
version: 2.4.6
jsdom:
specifier: ^29.0.1
version: 29.0.1
specifier: ^28.0.0
version: 28.0.0
oxlint:
specifier: ^1.2.0
version: 1.47.0
@@ -52,13 +52,13 @@ importers:
version: 2.6.1
jsdom:
specifier: 'catalog:'
version: 29.0.1
version: 28.0.0
scule:
specifier: ^1.3.0
version: 1.3.0
vitest:
specifier: 'catalog:'
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@29.0.1)(terser@5.44.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@28.0.0)(terser@5.44.0)(yaml@2.8.2)
configs/oxlint:
devDependencies:
@@ -164,16 +164,17 @@ importers:
packages:
'@acemir/cssom@0.9.31':
resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
'@arcanis/slice-ansi@1.1.1':
resolution: {integrity: sha512-xguP2WR2Dv0gQ7Ykbdb7BNCnPnIPB94uTi0Z2NvkRBEnhbwjOQ7QyQKJXrVQg4qDpiD9hA5l5cCwy/z2OXgc3w==}
'@asamuzakjp/css-color@5.0.1':
resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/css-color@4.1.2':
resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==}
'@asamuzakjp/dom-selector@7.0.3':
resolution: {integrity: sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/dom-selector@6.7.8':
resolution: {integrity: sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==}
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
@@ -433,10 +434,6 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@breejs/later@4.2.0':
resolution: {integrity: sha512-EVMD0SgJtOuFeg0lAVbCwa+qeTKILb87jqvLyUtQswGD9+ce2nB52Y5zbTF1Hc0MDFfbydcMcxb47jSdhikVHA==}
engines: {node: '>= 10'}
@@ -444,8 +441,8 @@ packages:
'@cdktf/hcl2json@0.21.0':
resolution: {integrity: sha512-cwX3i/mSJI/cRrtqwEPRfawB7pXgNioriSlkvou8LWiCrrcDe9ZtTbAbu8W1tEJQpe1pnX9VEgpzf/BbM7xF8Q==}
'@csstools/color-helpers@6.0.2':
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
'@csstools/color-helpers@6.0.1':
resolution: {integrity: sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==}
engines: {node: '>=20.19.0'}
'@csstools/css-calc@3.1.1':
@@ -455,8 +452,8 @@ packages:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-color-parser@4.0.2':
resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==}
'@csstools/css-color-parser@4.0.1':
resolution: {integrity: sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
@@ -468,13 +465,8 @@ packages:
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.1':
resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==}
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
'@csstools/css-syntax-patches-for-csstree@1.0.27':
resolution: {integrity: sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==}
'@csstools/css-tokenizer@4.0.0':
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
@@ -645,8 +637,8 @@ packages:
cpu: [x64]
os: [win32]
'@exodus/bytes@1.15.0':
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
'@exodus/bytes@1.14.1':
resolution: {integrity: sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
@@ -2059,14 +2051,18 @@ packages:
css-select@5.1.0:
resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==}
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
css-tree@3.1.0:
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
css-what@6.1.0:
resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==}
engines: {node: '>= 6'}
cssstyle@5.3.7:
resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==}
engines: {node: '>=20'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -2468,7 +2464,6 @@ packages:
git-raw-commits@2.0.11:
resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==}
engines: {node: '>=10'}
deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead.
hasBin: true
git-up@8.1.1:
@@ -2792,9 +2787,9 @@ packages:
jsbn@1.1.0:
resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==}
jsdom@29.0.1:
resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
jsdom@28.0.0:
resolution: {integrity: sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
@@ -2892,12 +2887,12 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.2.6:
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
lru-cache@11.2.1:
resolution: {integrity: sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==}
engines: {node: 20 || >=22}
lru-cache@11.2.7:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
lru-cache@11.2.6:
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
engines: {node: 20 || >=22}
lru-cache@6.0.0:
@@ -2974,8 +2969,8 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
@@ -3456,7 +3451,6 @@ packages:
prebuild-install@7.1.2:
resolution: {integrity: sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
prettier@3.6.2:
@@ -3883,7 +3877,6 @@ packages:
tar@7.5.7:
resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==}
engines: {node: '>=18'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
terser@5.44.0:
resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==}
@@ -3943,8 +3936,8 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tough-cookie@6.0.1:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
tough-cookie@6.0.0:
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
engines: {node: '>=16'}
tr46@0.0.3:
@@ -4048,8 +4041,8 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@7.24.5:
resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==}
undici@7.21.0:
resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==}
engines: {node: '>=20.18.1'}
unicorn-magic@0.3.0:
@@ -4222,8 +4215,8 @@ packages:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
whatwg-url@16.0.1:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
whatwg-url@16.0.0:
resolution: {integrity: sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
whatwg-url@5.0.0:
@@ -4323,25 +4316,27 @@ packages:
snapshots:
'@acemir/cssom@0.9.31': {}
'@arcanis/slice-ansi@1.1.1':
dependencies:
grapheme-splitter: 1.0.4
'@asamuzakjp/css-color@5.0.1':
'@asamuzakjp/css-color@4.1.2':
dependencies:
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-color-parser': 4.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
lru-cache: 11.2.7
lru-cache: 11.2.6
'@asamuzakjp/dom-selector@7.0.3':
'@asamuzakjp/dom-selector@6.7.8':
dependencies:
'@asamuzakjp/nwsapi': 2.3.9
bidi-js: 1.0.3
css-tree: 3.2.1
css-tree: 3.1.0
is-potential-custom-element-name: 1.0.1
lru-cache: 11.2.7
lru-cache: 11.2.6
'@asamuzakjp/nwsapi@2.3.9': {}
@@ -5252,26 +5247,22 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
'@breejs/later@4.2.0': {}
'@cdktf/hcl2json@0.21.0':
dependencies:
fs-extra: 11.3.0
'@csstools/color-helpers@6.0.2': {}
'@csstools/color-helpers@6.0.1': {}
'@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
'@csstools/css-color-parser@4.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/color-helpers': 6.0.2
'@csstools/color-helpers': 6.0.1
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
@@ -5280,9 +5271,7 @@ snapshots:
dependencies:
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)':
optionalDependencies:
css-tree: 3.2.1
'@csstools/css-syntax-patches-for-csstree@1.0.27': {}
'@csstools/css-tokenizer@4.0.0': {}
@@ -5380,7 +5369,7 @@ snapshots:
'@esbuild/win32-x64@0.25.9':
optional: true
'@exodus/bytes@1.15.0': {}
'@exodus/bytes@1.14.1': {}
'@gwhitney/detect-indent@7.0.1': {}
@@ -5456,7 +5445,7 @@ snapshots:
agent-base: 7.1.3
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
lru-cache: 11.2.6
lru-cache: 11.2.1
socks-proxy-agent: 8.0.3
transitivePeerDependencies:
- supports-color
@@ -6417,7 +6406,7 @@ snapshots:
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@29.0.1)(terser@5.44.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@28.0.0)(terser@5.44.0)(yaml@2.8.2)
'@vitest/expect@4.0.18':
dependencies:
@@ -6462,7 +6451,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@29.0.1)(terser@5.44.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@28.0.0)(terser@5.44.0)(yaml@2.8.2)
'@vitest/utils@4.0.18':
dependencies:
@@ -6740,7 +6729,7 @@ snapshots:
'@npmcli/fs': 5.0.0
fs-minipass: 3.0.3
glob: 13.0.1
lru-cache: 11.2.6
lru-cache: 11.2.1
minipass: 7.1.2
minipass-collect: 2.0.1
minipass-flush: 1.0.5
@@ -6870,13 +6859,20 @@ snapshots:
domutils: 3.1.0
nth-check: 2.1.1
css-tree@3.2.1:
css-tree@3.1.0:
dependencies:
mdn-data: 2.27.1
mdn-data: 2.12.2
source-map-js: 1.2.1
css-what@6.1.0: {}
cssstyle@5.3.7:
dependencies:
'@asamuzakjp/css-color': 4.1.2
'@csstools/css-syntax-patches-for-csstree': 1.0.27
css-tree: 3.1.0
lru-cache: 11.2.6
csstype@3.2.3: {}
dargs@7.0.0: {}
@@ -6886,7 +6882,7 @@ snapshots:
data-urls@7.0.0:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
whatwg-url: 16.0.0
transitivePeerDependencies:
- '@noble/hashes'
@@ -7440,7 +7436,7 @@ snapshots:
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.15.0
'@exodus/bytes': 1.14.1
transitivePeerDependencies:
- '@noble/hashes'
@@ -7454,7 +7450,6 @@ snapshots:
debug: 4.4.1
transitivePeerDependencies:
- supports-color
optional: true
http2-wrapper@1.0.3:
dependencies:
@@ -7625,31 +7620,31 @@ snapshots:
jsbn@1.1.0:
optional: true
jsdom@29.0.1:
jsdom@28.0.0:
dependencies:
'@asamuzakjp/css-color': 5.0.1
'@asamuzakjp/dom-selector': 7.0.3
'@bramus/specificity': 2.4.2
'@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1)
'@exodus/bytes': 1.15.0
css-tree: 3.2.1
'@acemir/cssom': 0.9.31
'@asamuzakjp/dom-selector': 6.7.8
'@exodus/bytes': 1.14.1
cssstyle: 5.3.7
data-urls: 7.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 6.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
lru-cache: 11.2.7
parse5: 8.0.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.1
undici: 7.24.5
tough-cookie: 6.0.0
undici: 7.21.0
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
whatwg-url: 16.0.0
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
- supports-color
jsesc@3.1.0: {}
@@ -7732,9 +7727,9 @@ snapshots:
lru-cache@10.4.3: {}
lru-cache@11.2.6: {}
lru-cache@11.2.1: {}
lru-cache@11.2.7: {}
lru-cache@11.2.6: {}
lru-cache@6.0.0:
dependencies:
@@ -7894,7 +7889,7 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
mdn-data@2.27.1: {}
mdn-data@2.12.2: {}
mdurl@2.0.0: {}
@@ -8465,7 +8460,7 @@ snapshots:
path-scurry@2.0.0:
dependencies:
lru-cache: 11.2.6
lru-cache: 11.2.1
minipass: 7.1.2
pathe@2.0.3: {}
@@ -9216,7 +9211,7 @@ snapshots:
totalist@3.0.1: {}
tough-cookie@6.0.1:
tough-cookie@6.0.0:
dependencies:
tldts: 7.0.23
@@ -9311,7 +9306,7 @@ snapshots:
undici-types@7.16.0: {}
undici@7.24.5: {}
undici@7.21.0: {}
unicorn-magic@0.3.0: {}
@@ -9406,7 +9401,7 @@ snapshots:
terser: 5.44.0
yaml: 2.8.2
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@29.0.1)(terser@5.44.0)(yaml@2.8.2):
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@28.0.0)(terser@5.44.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(vite@7.1.4(@types/node@24.10.13)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.2))
@@ -9432,7 +9427,7 @@ snapshots:
'@opentelemetry/api': 1.9.0
'@types/node': 24.10.13
'@vitest/ui': 4.0.18(vitest@4.0.18)
jsdom: 29.0.1
jsdom: 28.0.0
transitivePeerDependencies:
- jiti
- less
@@ -9470,9 +9465,9 @@ snapshots:
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.1:
whatwg-url@16.0.0:
dependencies:
'@exodus/bytes': 1.15.0
'@exodus/bytes': 1.14.1
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:

View File

@@ -8,7 +8,7 @@ packages:
catalog:
'@vitest/coverage-v8': ^4.0.18
'@vue/test-utils': ^2.4.6
jsdom: ^29.0.1
jsdom: ^28.0.0
oxlint: ^1.2.0
tsdown: ^0.12.5
vitest: ^4.0.18

View File

@@ -22,6 +22,7 @@ describe(useAsyncState, () => {
expect(isLoading.value).toBeTruthy();
expect(error.value).toBe(null);
await nextTick();
await nextTick();
expect(state.value).toBe('data');
@@ -41,6 +42,7 @@ describe(useAsyncState, () => {
expect(isLoading.value).toBeTruthy();
expect(error.value).toBe(null);
await nextTick();
await nextTick();
expect(state.value).toBe('data');
@@ -60,6 +62,7 @@ describe(useAsyncState, () => {
expect(isLoading.value).toBeTruthy();
expect(error.value).toBe(null);
await nextTick();
await nextTick();
expect(state.value).toBe('initial');
@@ -77,6 +80,7 @@ describe(useAsyncState, () => {
{ onSuccess },
);
await nextTick();
await nextTick();
expect(onSuccess).toHaveBeenCalledWith('data');
@@ -92,6 +96,7 @@ describe(useAsyncState, () => {
{ onError },
);
await nextTick();
await nextTick();
expect(onError).toHaveBeenCalledWith(error);
@@ -164,6 +169,7 @@ describe(useAsyncState, () => {
expect(isReady.value).toBeFalsy();
expect(error.value).toBe(null);
await nextTick();
await nextTick();
expect(state.value).toBe('data');
@@ -206,4 +212,114 @@ describe(useAsyncState, () => {
expect(state.value.a).toBe(1);
expect(isShallow(state)).toBeFalsy();
});
it('aborts pending execution', async () => {
let resolvePromise: (value: string) => void;
const promiseFn = () => new Promise<string>(resolve => { resolvePromise = resolve; });
const { state, isLoading, abort, executeImmediately } = useAsyncState(
promiseFn,
'initial',
{ immediate: false },
);
executeImmediately();
expect(isLoading.value).toBeTruthy();
abort();
expect(isLoading.value).toBeFalsy();
resolvePromise!('data');
await nextTick();
expect(state.value).toBe('initial');
});
it('abort prevents state update from resolved promise', async () => {
let resolvePromise: (value: string) => void;
const promiseFn = () => new Promise<string>(resolve => { resolvePromise = resolve; });
const { state, isLoading, isReady, abort, executeImmediately } = useAsyncState(
promiseFn,
'initial',
{ immediate: false },
);
executeImmediately();
expect(isLoading.value).toBeTruthy();
abort();
expect(isLoading.value).toBeFalsy();
resolvePromise!('data');
await nextTick();
expect(state.value).toBe('initial');
expect(isReady.value).toBeFalsy();
});
it('abort prevents error handling from rejected promise', async () => {
let rejectPromise: (error: Error) => void;
const promiseFn = () => new Promise<string>((_, reject) => { rejectPromise = reject; });
const onError = vi.fn();
const { error, abort, executeImmediately } = useAsyncState(
promiseFn,
'initial',
{ immediate: false, onError },
);
executeImmediately();
abort();
rejectPromise!(new Error('test-error'));
await nextTick();
expect(error.value).toBe(null);
expect(onError).not.toHaveBeenCalled();
});
it('new execute after abort works correctly', async () => {
let resolvePromise: (value: string) => void;
const promiseFn = () => new Promise<string>(resolve => { resolvePromise = resolve; });
const { state, isReady, abort, executeImmediately } = useAsyncState(
promiseFn,
'initial',
{ immediate: false },
);
executeImmediately();
abort();
executeImmediately();
resolvePromise!('new data');
await nextTick();
await nextTick();
expect(state.value).toBe('new data');
expect(isReady.value).toBeTruthy();
});
it('re-execute cancels previous pending execution', async () => {
let callCount = 0;
const promiseFn = (value: string) => new Promise<string>(resolve => {
callCount++;
setTimeout(() => resolve(value), 100);
});
const { state, executeImmediately } = useAsyncState(
promiseFn,
'initial',
{ immediate: false },
);
executeImmediately('first');
executeImmediately('second');
await vi.advanceTimersByTimeAsync(100);
expect(state.value).toBe('second');
expect(callCount).toBe(2);
});
});

View File

@@ -1,6 +1,6 @@
import { ref, shallowRef, watch } from 'vue';
import type { Ref, ShallowRef, UnwrapRef } from 'vue';
import { isFunction, sleep } from '@robonen/stdlib';
import { isFunction, sleep, cancellablePromise as makeCancellable, CancelledError } from '@robonen/stdlib';
export interface UseAsyncStateOptions<Shallow extends boolean, Data = any> {
delay?: number;
@@ -19,6 +19,7 @@ export interface UseAsyncStateReturnBase<Data, Params extends any[], Shallow ext
error: Ref<unknown | null>;
execute: (delay?: number, ...params: Params) => Promise<Data>;
executeImmediately: (...params: Params) => Promise<Data>;
abort: () => void;
}
export type UseAsyncStateReturn<Data, Params extends any[], Shallow extends boolean> =
@@ -50,7 +51,14 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
const isLoading = ref(false);
const isReady = ref(false);
let cancelPending: ((reason?: string) => void) | undefined;
const execute = async (actualDelay = delay, ...params: any[]) => {
cancelPending?.();
let active = true;
cancelPending = () => { active = false; };
if (resetOnExecute)
state.value = initialState;
@@ -61,15 +69,27 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
if (actualDelay > 0)
await sleep(actualDelay);
const promise = isFunction(maybePromise) ? maybePromise(...params as Params) : maybePromise;
if (!active)
return state.value as Data;
const rawPromise = isFunction(maybePromise) ? maybePromise(...params as Params) : maybePromise;
const { promise, cancel } = makeCancellable(rawPromise);
cancelPending = (reason?: string) => {
active = false;
cancel(reason);
};
try {
const data = await promise;
state.value = data;
isReady.value = true;
onSuccess?.(data);
}
catch (e: unknown) {
if (e instanceof CancelledError)
return state.value as Data;
error.value = e;
onError?.(e);
@@ -77,6 +97,7 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
throw e;
}
finally {
if (active)
isLoading.value = false;
}
@@ -87,6 +108,12 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
return execute(0, ...params);
};
const abort = () => {
cancelPending?.();
cancelPending = undefined;
isLoading.value = false;
};
if (immediate)
execute();
@@ -97,14 +124,17 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
error,
execute,
executeImmediately,
abort,
};
function waitResolve() {
return new Promise<UseAsyncStateReturnBase<Data, Params, Shallow>>((resolve, reject) => {
watch(
const unwatch = watch(
isLoading,
(loading) => {
if (loading === false) {
unwatch();
if (error.value)
reject(error.value);
else
@@ -113,7 +143,6 @@ export function useAsyncState<Data, Params extends any[] = [], Shallow extends b
},
{
immediate: true,
once: true,
flush: 'sync',
},
);