@pistonite/pure 0.0.17 → 0.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +38 -34
- package/src/fs/FsFileMgr.ts +6 -6
- package/src/pref/dark.ts +44 -52
- package/src/pref/locale.ts +34 -112
- package/src/sync/RwLock.ts +4 -0
- package/src/sync/batch.test.ts +116 -0
- package/src/sync/batch.ts +3 -0
- package/src/sync/debounce.test.ts +123 -0
- package/src/sync/debounce.ts +3 -1
- package/src/sync/index.ts +1 -0
- package/src/sync/latest.test.ts +18 -0
- package/src/sync/latest.ts +2 -0
- package/src/sync/once.test.ts +19 -0
- package/src/sync/once.ts +109 -0
- package/src/sync/persist.ts +3 -1
- package/src/sync/serial.test.ts +97 -0
- package/src/sync/serial.ts +2 -0
- package/src/sync/util.ts +6 -1
package/package.json
CHANGED
|
@@ -1,35 +1,39 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
2
|
+
"name": "@pistonite/pure",
|
|
3
|
+
"version": "0.0.19",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Pure TypeScript libraries for my projects",
|
|
6
|
+
"homepage": "https://github.com/Pistonite/pure",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/Pistonite/pure/issues"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "Pistonight <pistonknight@outlook.com>",
|
|
12
|
+
"files": [
|
|
13
|
+
"src/**/*",
|
|
14
|
+
"!src/**/*.test.ts"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
"./fs": "./src/fs/index.ts",
|
|
18
|
+
"./log": "./src/log/index.ts",
|
|
19
|
+
"./result": "./src/result/index.ts",
|
|
20
|
+
"./sync": "./src/sync/index.ts",
|
|
21
|
+
"./pref": "./src/pref/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/Pistonite/pure.git",
|
|
26
|
+
"directory": "packages/pure"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"denque": "2.1.0",
|
|
30
|
+
"file-saver": "2.0.5"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/file-saver": "^2.0.7",
|
|
34
|
+
"eslint": "^9.19.0",
|
|
35
|
+
"typescript": "^5.7.2",
|
|
36
|
+
"vitest": "^2.1.8",
|
|
37
|
+
"mono-dev": "0.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/fs/FsFileMgr.ts
CHANGED
|
@@ -4,26 +4,26 @@ import { fsFile } from "./FsFileImpl.ts";
|
|
|
4
4
|
|
|
5
5
|
/** Internal class to track opened files */
|
|
6
6
|
export class FsFileMgr {
|
|
7
|
-
private opened:
|
|
7
|
+
private opened: Map<string, FsFile>;
|
|
8
8
|
|
|
9
9
|
public constructor() {
|
|
10
|
-
this.opened =
|
|
10
|
+
this.opened = new Map();
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
public get(fs: FsFileSystemInternal, path: string): FsFile {
|
|
14
|
-
let file = this.opened
|
|
14
|
+
let file = this.opened.get(path);
|
|
15
15
|
if (!file) {
|
|
16
16
|
file = fsFile(fs, path);
|
|
17
|
-
this.opened
|
|
17
|
+
this.opened.set(path, file);
|
|
18
18
|
}
|
|
19
19
|
return file;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
public close(path: string): void {
|
|
23
|
-
|
|
23
|
+
this.opened.delete(path);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
public getOpenedPaths(): string[] {
|
|
27
|
-
return
|
|
27
|
+
return Array.from(this.opened.keys());
|
|
28
28
|
}
|
|
29
29
|
}
|
package/src/pref/dark.ts
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
|
+
import { persist } from "../sync/persist.ts";
|
|
2
|
+
import { injectStyle } from "./injectStyle.ts";
|
|
3
|
+
|
|
4
|
+
const dark = persist({
|
|
5
|
+
initial: false,
|
|
6
|
+
key: "Pure.Dark",
|
|
7
|
+
storage: localStorage,
|
|
8
|
+
serialize: (value) => (value ? "1" : ""),
|
|
9
|
+
deserialize: (value) => !!value,
|
|
10
|
+
});
|
|
11
|
+
|
|
1
12
|
/**
|
|
2
|
-
*
|
|
13
|
+
* Returns if dark mode is prefered in the browser environment
|
|
14
|
+
*
|
|
15
|
+
* If `window.matchMedia` is not available, it will return `false`
|
|
16
|
+
*/
|
|
17
|
+
export const prefersDarkMode = (): boolean => {
|
|
18
|
+
return !!globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Value for the `color-scheme` CSS property */
|
|
22
|
+
export type ColorScheme = "light" | "dark";
|
|
23
|
+
/** Option for initializing dark mode */
|
|
24
|
+
export type DarkOptions = {
|
|
25
|
+
/**
|
|
26
|
+
* Initial value for dark mode
|
|
27
|
+
*
|
|
28
|
+
* If not set, it will default to calling `prefersDarkMode()`.
|
|
29
|
+
*
|
|
30
|
+
* If `persist` is `true`, it will also check the value from localStorage
|
|
31
|
+
*/
|
|
32
|
+
initial?: boolean;
|
|
33
|
+
/** Persist the dark mode preference to localStorage */
|
|
34
|
+
persist?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* The selector to set `color-scheme` property
|
|
37
|
+
*
|
|
38
|
+
* Defaults to `:root`. If set to empty string, CSS will not be updated
|
|
39
|
+
*/
|
|
40
|
+
selector?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Init Dark mode wrappers
|
|
3
45
|
*
|
|
4
46
|
* ## Detect user preference
|
|
5
47
|
* User preference is detected with `matchMedia` API, if available.
|
|
@@ -46,59 +88,9 @@
|
|
|
46
88
|
* // will set `.my-app { color-scheme: dark }`
|
|
47
89
|
* initDark({ selector: ".my-app" });
|
|
48
90
|
* ```
|
|
49
|
-
*
|
|
50
|
-
* @module
|
|
51
|
-
*/
|
|
52
|
-
|
|
53
|
-
import { persist } from "../sync/persist.ts";
|
|
54
|
-
import { injectStyle } from "./injectStyle.ts";
|
|
55
|
-
|
|
56
|
-
const dark = persist({
|
|
57
|
-
initial: false,
|
|
58
|
-
key: "Pure.Dark",
|
|
59
|
-
storage: localStorage,
|
|
60
|
-
serialize: (value) => (value ? "1" : ""),
|
|
61
|
-
deserialize: (value) => !!value,
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Returns if dark mode is prefered in the browser environment
|
|
66
|
-
*
|
|
67
|
-
* If `window.matchMedia` is not available, it will return `false`
|
|
68
|
-
*/
|
|
69
|
-
export const prefersDarkMode = (): boolean => {
|
|
70
|
-
return !!globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
/** Value for the `color-scheme` CSS property */
|
|
74
|
-
export type ColorScheme = "light" | "dark";
|
|
75
|
-
/** Option for initializing dark mode */
|
|
76
|
-
export type DarkOptions = {
|
|
77
|
-
/**
|
|
78
|
-
* Initial value for dark mode
|
|
79
|
-
*
|
|
80
|
-
* If not set, it will default to calling `prefersDarkMode()`.
|
|
81
|
-
*
|
|
82
|
-
* If `persist` is `true`, it will also check the value from localStorage
|
|
83
|
-
*/
|
|
84
|
-
initial?: boolean;
|
|
85
|
-
/** Persist the dark mode preference to localStorage */
|
|
86
|
-
persist?: boolean;
|
|
87
|
-
/**
|
|
88
|
-
* The selector to set `color-scheme` property
|
|
89
|
-
*
|
|
90
|
-
* Defaults to `:root`. If set to empty string, CSS will not be updated
|
|
91
|
-
*/
|
|
92
|
-
selector?: string;
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Initializes dark mode
|
|
97
|
-
*
|
|
98
|
-
* @param options Options for initializing dark mode
|
|
99
91
|
*/
|
|
100
92
|
export const initDark = (options: DarkOptions = {}): void => {
|
|
101
|
-
|
|
93
|
+
const _dark = options.initial || prefersDarkMode();
|
|
102
94
|
|
|
103
95
|
const selector = options.selector ?? ":root";
|
|
104
96
|
if (selector) {
|
package/src/pref/locale.ts
CHANGED
|
@@ -1,58 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Locale utilities and integration with i18next
|
|
3
|
-
*
|
|
4
|
-
* ## Initialization
|
|
5
|
-
* `initLocale` must be called before using the other functions.
|
|
6
|
-
*
|
|
7
|
-
* ```typescript
|
|
8
|
-
* import { initLocale } from "@pistonite/pure/pref";
|
|
9
|
-
*
|
|
10
|
-
* initLocale({
|
|
11
|
-
* // required
|
|
12
|
-
* supported: ["en", "zh-CN", "zh-TW"] as const,
|
|
13
|
-
* default: "en",
|
|
14
|
-
*
|
|
15
|
-
* // optional
|
|
16
|
-
* persist: true, // save to localStorage
|
|
17
|
-
* initial: "en-US", // initial value, instead of detecting
|
|
18
|
-
* });
|
|
19
|
-
* ```
|
|
20
|
-
*
|
|
21
|
-
* ## Connecting with i18next
|
|
22
|
-
* The typical usage for this component is to use i18next for localization.
|
|
23
|
-
* This module provides 2 plugins:
|
|
24
|
-
* - `detectLocale`:
|
|
25
|
-
* - Provide the current language to i18next (as a language detector)
|
|
26
|
-
* - Update the global locale state whenever `i18next.changeLanguage` is called
|
|
27
|
-
* - `connectI18next`:
|
|
28
|
-
* - Call `i18next.changeLanguage` whenever `setLocale` is called
|
|
29
|
-
*
|
|
30
|
-
* You might only need one of these plugins, depending on your use case.
|
|
31
|
-
* For example, if you will never call `setLocale` manually, then you don't need `connectI18next`.
|
|
32
|
-
*
|
|
33
|
-
* ```typescript
|
|
34
|
-
* import i18next from "i18next";
|
|
35
|
-
* import { initLocale, detectLocale, connectI18next } from "@pistonite/pure/pref";
|
|
36
|
-
*
|
|
37
|
-
* // initialize locale
|
|
38
|
-
* initLocale({ supported: ["en", "es"], default: "en", persist: true });
|
|
39
|
-
*
|
|
40
|
-
* // connect with i18next
|
|
41
|
-
* i18next.use(detectLocale).use(connectI18next).init({
|
|
42
|
-
* // ...other options not shown
|
|
43
|
-
* });
|
|
44
|
-
* ```
|
|
45
|
-
*
|
|
46
|
-
* ## Use with React
|
|
47
|
-
* A React hook is provided in the [`pure-react`](https://jsr.io/@pistonite/pure-react/doc/pref) package
|
|
48
|
-
* to get the current locale from React components.
|
|
49
|
-
*
|
|
50
|
-
* Changing the locale from React components is the same as from outside React,
|
|
51
|
-
* with `setLocale` or `i18next.changeLanguage`, depending on your setup.
|
|
52
|
-
*
|
|
53
|
-
* @module
|
|
54
|
-
*/
|
|
55
|
-
|
|
56
1
|
import { persist } from "../sync/persist.ts";
|
|
57
2
|
|
|
58
3
|
let supportedLocales: readonly string[] = [];
|
|
@@ -114,7 +59,37 @@ export type LocaleOptions<TLocale extends string> = {
|
|
|
114
59
|
persist?: boolean;
|
|
115
60
|
};
|
|
116
61
|
|
|
117
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Initialize Locale utilities
|
|
64
|
+
*
|
|
65
|
+
* `initLocale` must be called before using the other functions.
|
|
66
|
+
*
|
|
67
|
+
* ```typescript
|
|
68
|
+
* import { initLocale } from "@pistonite/pure/pref";
|
|
69
|
+
*
|
|
70
|
+
* initLocale({
|
|
71
|
+
* // required
|
|
72
|
+
* supported: ["en", "zh-CN", "zh-TW"] as const,
|
|
73
|
+
* default: "en",
|
|
74
|
+
*
|
|
75
|
+
* // optional
|
|
76
|
+
* persist: true, // save to localStorage
|
|
77
|
+
* initial: "en-US", // initial value, instead of detecting
|
|
78
|
+
* });
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* ## Connecting with i18next
|
|
82
|
+
* The `@pistonite/pure-i18next` package provides additional wrapper
|
|
83
|
+
* for connecting with i18next. See the documentation there for more details.
|
|
84
|
+
* You will use `initLocaleWithI18next` instead of `initLocale`.
|
|
85
|
+
*
|
|
86
|
+
* ## Use with React
|
|
87
|
+
* A React hook is provided in the [`pure-react`](https://jsr.io/@pistonite/pure-react/doc/pref) package
|
|
88
|
+
* to get the current locale from React components.
|
|
89
|
+
*
|
|
90
|
+
* Changing the locale from React components is the same as from outside React,
|
|
91
|
+
* with `setLocale` or `i18next.changeLanguage`, depending on your setup.
|
|
92
|
+
*/
|
|
118
93
|
export const initLocale = <TLocale extends string>(
|
|
119
94
|
options: LocaleOptions<TLocale>,
|
|
120
95
|
): void => {
|
|
@@ -236,62 +211,6 @@ export const addLocaleSubscriber = (
|
|
|
236
211
|
return locale.subscribe(fn, notifyImmediately);
|
|
237
212
|
};
|
|
238
213
|
|
|
239
|
-
/**
|
|
240
|
-
* Language detector plugin for i18next
|
|
241
|
-
*
|
|
242
|
-
* **Must call `initLocale` before initializaing i18next**
|
|
243
|
-
*
|
|
244
|
-
* This also sets the global locale state whenever `i18next.changeLanguage` is called.
|
|
245
|
-
*
|
|
246
|
-
* # Example
|
|
247
|
-
* ```typescript
|
|
248
|
-
* import i18next from "i18next";
|
|
249
|
-
* import { initLocale, detectLocale } from "@pistonite/pure/pref";
|
|
250
|
-
*
|
|
251
|
-
* initLocale({ supported: ["en", "es"], default: "en", persist: true });
|
|
252
|
-
*
|
|
253
|
-
* i18next.use(detectLocale).init({
|
|
254
|
-
* // don't need to specify `lng` here
|
|
255
|
-
*
|
|
256
|
-
* // ...other options not shown
|
|
257
|
-
* });
|
|
258
|
-
* ```
|
|
259
|
-
*
|
|
260
|
-
*/
|
|
261
|
-
export const detectLocale = {
|
|
262
|
-
type: "languageDetector" as const,
|
|
263
|
-
detect: (): string => locale.get(),
|
|
264
|
-
cacheUserLanguage: (lng: string): void => {
|
|
265
|
-
setLocale(lng);
|
|
266
|
-
},
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Bind the locale state to i18next, so whenever `setLocale`
|
|
271
|
-
* is called, it will also call `i18next.changeLanguage`.
|
|
272
|
-
*
|
|
273
|
-
* # Example
|
|
274
|
-
* ```typescript
|
|
275
|
-
* import i18next from "i18next";
|
|
276
|
-
* import { connectI18next, initLocale } from "@pistonite/pure/pref";
|
|
277
|
-
*
|
|
278
|
-
* initLocale({ supported: ["en", "es"], default: "en", persist: true });
|
|
279
|
-
* i18next.use(connectI18next).init({
|
|
280
|
-
* // ...options
|
|
281
|
-
* });
|
|
282
|
-
*
|
|
283
|
-
*/
|
|
284
|
-
export const connectI18next = {
|
|
285
|
-
type: "3rdParty" as const,
|
|
286
|
-
init: (i18next: any): void => {
|
|
287
|
-
addLocaleSubscriber((locale) => {
|
|
288
|
-
if (i18next.language !== locale) {
|
|
289
|
-
i18next.changeLanguage(locale);
|
|
290
|
-
}
|
|
291
|
-
}, true);
|
|
292
|
-
},
|
|
293
|
-
};
|
|
294
|
-
|
|
295
214
|
const localizedLanguageNames = new Map();
|
|
296
215
|
|
|
297
216
|
/**
|
|
@@ -316,3 +235,6 @@ export const getLocalizedLanguageName = (language: string): string => {
|
|
|
316
235
|
localizedLanguageNames.set(language, localized);
|
|
317
236
|
return localized || language;
|
|
318
237
|
};
|
|
238
|
+
|
|
239
|
+
/** Get the array of supported locales passed to init */
|
|
240
|
+
export const getSupportedLocales = (): readonly string[] => supportedLocales;
|
package/src/sync/RwLock.ts
CHANGED
|
@@ -44,12 +44,14 @@ export class RwLock<TRead, TWrite extends TRead = TRead> {
|
|
|
44
44
|
if (this.writeWaiters.length > 0) {
|
|
45
45
|
if (this.readers === 0) {
|
|
46
46
|
// notify one writer
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
47
48
|
this.writeWaiters.shift()!();
|
|
48
49
|
}
|
|
49
50
|
// don't notify anyone if there are still readers
|
|
50
51
|
} else {
|
|
51
52
|
// notify all readers
|
|
52
53
|
while (this.readWaiters.length > 0) {
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
53
55
|
this.readWaiters.shift()!();
|
|
54
56
|
}
|
|
55
57
|
}
|
|
@@ -85,9 +87,11 @@ export class RwLock<TRead, TWrite extends TRead = TRead> {
|
|
|
85
87
|
this.isWriting = false;
|
|
86
88
|
if (this.readWaiters.length > 0) {
|
|
87
89
|
// notify one reader
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
88
91
|
this.readWaiters.shift()!();
|
|
89
92
|
} else if (this.writeWaiters.length > 0) {
|
|
90
93
|
// notify one writer
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
91
95
|
this.writeWaiters.shift()!();
|
|
92
96
|
}
|
|
93
97
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { batch } from "./batch.ts";
|
|
4
|
+
|
|
5
|
+
describe("batch", () => {
|
|
6
|
+
// sum the inputs
|
|
7
|
+
const batchFn = vi.fn((inputs: [number][]): [number] => {
|
|
8
|
+
const out = inputs.reduce((acc, [x]) => acc + x, 0);
|
|
9
|
+
return [out];
|
|
10
|
+
});
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.useFakeTimers();
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.runAllTimers();
|
|
16
|
+
vi.useRealTimers();
|
|
17
|
+
batchFn.mockClear();
|
|
18
|
+
});
|
|
19
|
+
test("initial call executed immediately", async () => {
|
|
20
|
+
const fn = vi.fn();
|
|
21
|
+
const execute = batch({
|
|
22
|
+
fn: (x: number) => {
|
|
23
|
+
fn(x);
|
|
24
|
+
return x * 2;
|
|
25
|
+
},
|
|
26
|
+
batch: batchFn,
|
|
27
|
+
interval: 100,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const result = await execute(1);
|
|
31
|
+
expect(result).toStrictEqual(2);
|
|
32
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
33
|
+
expect(batchFn).not.toHaveBeenCalled();
|
|
34
|
+
});
|
|
35
|
+
test("does not batch call executed after interval if only one", async () => {
|
|
36
|
+
const fn = vi.fn();
|
|
37
|
+
const execute = batch({
|
|
38
|
+
fn: (x: number) => {
|
|
39
|
+
fn(x);
|
|
40
|
+
return x * 2;
|
|
41
|
+
},
|
|
42
|
+
batch: batchFn,
|
|
43
|
+
interval: 100,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const result = await execute(1);
|
|
47
|
+
expect(result).toStrictEqual(2);
|
|
48
|
+
const promise2 = execute(2);
|
|
49
|
+
vi.advanceTimersByTime(99);
|
|
50
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
51
|
+
vi.advanceTimersByTime(1);
|
|
52
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
53
|
+
expect(await promise2).toStrictEqual(4);
|
|
54
|
+
|
|
55
|
+
expect(batchFn).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
test("batch call executed after interval if more than 1 call", async () => {
|
|
58
|
+
const fn = vi.fn();
|
|
59
|
+
const execute = batch({
|
|
60
|
+
fn: (x: number) => {
|
|
61
|
+
fn(x);
|
|
62
|
+
return x * 2;
|
|
63
|
+
},
|
|
64
|
+
batch: batchFn,
|
|
65
|
+
interval: 100,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const result = await execute(1);
|
|
69
|
+
expect(result).toStrictEqual(2);
|
|
70
|
+
const promise2 = execute(2);
|
|
71
|
+
const promise3 = execute(3);
|
|
72
|
+
vi.advanceTimersByTime(99);
|
|
73
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
74
|
+
vi.advanceTimersByTime(1);
|
|
75
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
76
|
+
expect(await promise2).toStrictEqual(10);
|
|
77
|
+
expect(await promise3).toStrictEqual(10);
|
|
78
|
+
|
|
79
|
+
expect(batchFn).toHaveBeenCalledTimes(1);
|
|
80
|
+
expect(batchFn.mock.calls[0][0]).toStrictEqual([[2], [3]]);
|
|
81
|
+
});
|
|
82
|
+
test("unbatch", async () => {
|
|
83
|
+
const fn = vi.fn();
|
|
84
|
+
const unbatch = vi.fn(
|
|
85
|
+
(inputs: [number][], output: number): number[] => {
|
|
86
|
+
// not actual meaningful unbatching
|
|
87
|
+
return [output / inputs.length, output / inputs.length];
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
const execute = batch({
|
|
91
|
+
fn: (x: number) => {
|
|
92
|
+
fn(x);
|
|
93
|
+
return x * 2;
|
|
94
|
+
},
|
|
95
|
+
batch: batchFn,
|
|
96
|
+
unbatch,
|
|
97
|
+
interval: 100,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const result = await execute(1);
|
|
101
|
+
expect(result).toStrictEqual(2);
|
|
102
|
+
const promise2 = execute(2);
|
|
103
|
+
const promise3 = execute(3);
|
|
104
|
+
vi.advanceTimersByTime(99);
|
|
105
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
106
|
+
vi.advanceTimersByTime(1);
|
|
107
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
108
|
+
expect(await promise2).toStrictEqual(5);
|
|
109
|
+
expect(await promise3).toStrictEqual(5);
|
|
110
|
+
|
|
111
|
+
expect(batchFn).toHaveBeenCalledTimes(1);
|
|
112
|
+
expect(unbatch).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(unbatch.mock.calls[0][0]).toStrictEqual([[2], [3]]);
|
|
114
|
+
expect(unbatch.mock.calls[0][1]).toStrictEqual(10);
|
|
115
|
+
});
|
|
116
|
+
});
|
package/src/sync/batch.ts
CHANGED
|
@@ -87,6 +87,7 @@ import { makePromise, type AwaitRet } from "./util.ts";
|
|
|
87
87
|
* ```
|
|
88
88
|
*
|
|
89
89
|
*/
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
90
91
|
export function batch<TFn extends (...args: any[]) => any>({
|
|
91
92
|
fn,
|
|
92
93
|
batch,
|
|
@@ -107,6 +108,7 @@ export function batch<TFn extends (...args: any[]) => any>({
|
|
|
107
108
|
/**
|
|
108
109
|
* Options to construct a `batch` function
|
|
109
110
|
*/
|
|
111
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
110
112
|
export type BatchConstructor<TFn extends (...args: any[]) => any> = {
|
|
111
113
|
/** Function to be wrapped */
|
|
112
114
|
fn: TFn;
|
|
@@ -133,6 +135,7 @@ export type BatchConstructor<TFn extends (...args: any[]) => any> = {
|
|
|
133
135
|
disregardExecutionTime?: boolean;
|
|
134
136
|
};
|
|
135
137
|
|
|
138
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
136
139
|
class BatchImpl<TFn extends (...args: any[]) => any> {
|
|
137
140
|
private idle: boolean;
|
|
138
141
|
private scheduled: {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { debounce } from "./debounce.ts";
|
|
4
|
+
|
|
5
|
+
describe("debounce", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.useFakeTimers();
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.runAllTimers();
|
|
11
|
+
vi.useRealTimers();
|
|
12
|
+
});
|
|
13
|
+
test("initial call executed immediately", async () => {
|
|
14
|
+
const fn = vi.fn();
|
|
15
|
+
const execute = debounce({
|
|
16
|
+
fn: () => {
|
|
17
|
+
fn();
|
|
18
|
+
return 42;
|
|
19
|
+
},
|
|
20
|
+
interval: 100,
|
|
21
|
+
});
|
|
22
|
+
const result = await execute();
|
|
23
|
+
expect(result).toStrictEqual(42);
|
|
24
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
25
|
+
});
|
|
26
|
+
test("debounce call executed after interval", async () => {
|
|
27
|
+
const fn = vi.fn();
|
|
28
|
+
const execute = debounce({
|
|
29
|
+
fn: () => {
|
|
30
|
+
fn();
|
|
31
|
+
return 42;
|
|
32
|
+
},
|
|
33
|
+
interval: 100,
|
|
34
|
+
});
|
|
35
|
+
await execute();
|
|
36
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
37
|
+
const promise2 = execute();
|
|
38
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
39
|
+
vi.advanceTimersByTime(99);
|
|
40
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
41
|
+
vi.advanceTimersByTime(1);
|
|
42
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
43
|
+
expect(await promise2).toStrictEqual(42);
|
|
44
|
+
});
|
|
45
|
+
test("discard extra calls", async () => {
|
|
46
|
+
const fn = vi.fn();
|
|
47
|
+
const execute = debounce({
|
|
48
|
+
fn: () => {
|
|
49
|
+
fn();
|
|
50
|
+
return 42;
|
|
51
|
+
},
|
|
52
|
+
interval: 100,
|
|
53
|
+
});
|
|
54
|
+
await execute();
|
|
55
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
56
|
+
const promise2 = execute();
|
|
57
|
+
const promise3 = execute();
|
|
58
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
59
|
+
vi.advanceTimersByTime(99);
|
|
60
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
61
|
+
vi.advanceTimersByTime(1);
|
|
62
|
+
expect(fn).toHaveBeenCalledTimes(2); // not 3
|
|
63
|
+
expect(await promise2).toStrictEqual(42);
|
|
64
|
+
expect(await promise3).toStrictEqual(42);
|
|
65
|
+
});
|
|
66
|
+
test("function takes long to run", async () => {
|
|
67
|
+
const fn = vi.fn();
|
|
68
|
+
const execute = debounce({
|
|
69
|
+
fn: async (i: string) => {
|
|
70
|
+
fn(i);
|
|
71
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
72
|
+
return i + "out";
|
|
73
|
+
},
|
|
74
|
+
interval: 100,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const promise1 = execute("");
|
|
78
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
79
|
+
const promise2 = execute("2");
|
|
80
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
81
|
+
vi.advanceTimersByTime(99);
|
|
82
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
83
|
+
vi.advanceTimersByTime(1);
|
|
84
|
+
expect(fn).toHaveBeenCalledTimes(1); // 100 - not called yet
|
|
85
|
+
vi.advanceTimersByTime(149);
|
|
86
|
+
expect(fn).toHaveBeenCalledTimes(1); // 249 - not called yet
|
|
87
|
+
vi.advanceTimersByTime(1);
|
|
88
|
+
// 250 - 1 should be finished
|
|
89
|
+
expect(await promise1).toStrictEqual("out");
|
|
90
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
91
|
+
// 2 should be fired now
|
|
92
|
+
vi.advanceTimersByTime(150);
|
|
93
|
+
await promise2;
|
|
94
|
+
});
|
|
95
|
+
test("function takes long to run, disregardExecutionTime", async () => {
|
|
96
|
+
const fn = vi.fn();
|
|
97
|
+
const execute = debounce({
|
|
98
|
+
fn: async (i: string) => {
|
|
99
|
+
fn(i);
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
101
|
+
return i + "out";
|
|
102
|
+
},
|
|
103
|
+
interval: 100,
|
|
104
|
+
disregardExecutionTime: true,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// 0 - 1 called
|
|
108
|
+
// 100 - 2 called
|
|
109
|
+
// 150 - 1 finished
|
|
110
|
+
// 250 - 2 finished
|
|
111
|
+
const promise1 = execute("");
|
|
112
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
113
|
+
const promise2 = execute("2");
|
|
114
|
+
vi.advanceTimersByTime(99);
|
|
115
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
116
|
+
vi.advanceTimersByTime(100);
|
|
117
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
118
|
+
vi.advanceTimersByTime(50);
|
|
119
|
+
await promise1;
|
|
120
|
+
vi.advanceTimersByTime(100);
|
|
121
|
+
await promise2;
|
|
122
|
+
});
|
|
123
|
+
});
|
package/src/sync/debounce.ts
CHANGED
|
@@ -78,6 +78,7 @@ import { type AwaitRet, makePromise } from "./util.ts";
|
|
|
78
78
|
* });
|
|
79
79
|
* ```
|
|
80
80
|
*/
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
81
82
|
export function debounce<TFn extends (...args: any[]) => any>({
|
|
82
83
|
fn,
|
|
83
84
|
interval,
|
|
@@ -113,13 +114,14 @@ export type DebounceConstructor<TFn> = {
|
|
|
113
114
|
disregardExecutionTime?: boolean;
|
|
114
115
|
};
|
|
115
116
|
|
|
117
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
116
118
|
class DebounceImpl<TFn extends (...args: any[]) => any> {
|
|
117
119
|
private idle: boolean;
|
|
118
120
|
private next?: {
|
|
119
121
|
args: Parameters<TFn>;
|
|
120
122
|
promise: Promise<AwaitRet<TFn>>;
|
|
121
123
|
resolve: (result: AwaitRet<TFn>) => void;
|
|
122
|
-
reject: (error:
|
|
124
|
+
reject: (error: unknown) => void;
|
|
123
125
|
};
|
|
124
126
|
constructor(
|
|
125
127
|
private fn: TFn,
|
package/src/sync/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export { debounce, type DebounceConstructor } from "./debounce.ts";
|
|
|
10
10
|
export { batch, type BatchConstructor } from "./batch.ts";
|
|
11
11
|
export { cell, type CellConstructor, type Cell } from "./cell.ts";
|
|
12
12
|
export { persist, type PersistConstructor, type Persist } from "./persist.ts";
|
|
13
|
+
export { once, type OnceConstructor } from "./once.ts";
|
|
13
14
|
|
|
14
15
|
// unstable
|
|
15
16
|
export { RwLock } from "./RwLock.ts";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { latest } from "./latest.ts";
|
|
4
|
+
|
|
5
|
+
test("latest", async () => {
|
|
6
|
+
let counter = 0;
|
|
7
|
+
|
|
8
|
+
const execute = latest({
|
|
9
|
+
fn: () => {
|
|
10
|
+
return ++counter;
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const result1 = execute();
|
|
15
|
+
const result2 = execute();
|
|
16
|
+
expect(await result1).toStrictEqual(2);
|
|
17
|
+
expect(await result2).toStrictEqual(2);
|
|
18
|
+
});
|
package/src/sync/latest.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { makePromise } from "./util.ts";
|
|
|
27
27
|
* console.log(await result2); // 2
|
|
28
28
|
* ```
|
|
29
29
|
*/
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
31
|
export function latest<TFn extends (...args: any[]) => any>({
|
|
31
32
|
fn,
|
|
32
33
|
}: LatestConstructor<TFn>) {
|
|
@@ -38,6 +39,7 @@ export type LatestConstructor<TFn> = {
|
|
|
38
39
|
/** Function to be wrapped */
|
|
39
40
|
fn: TFn;
|
|
40
41
|
};
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
43
|
export class LatestImpl<TFn extends (...args: any[]) => any> {
|
|
42
44
|
private hasNewer: boolean;
|
|
43
45
|
private pending?: {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { once } from "./once.ts";
|
|
4
|
+
|
|
5
|
+
test("once", async () => {
|
|
6
|
+
let counter = 0;
|
|
7
|
+
|
|
8
|
+
const execute = once({
|
|
9
|
+
fn: async () => {
|
|
10
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
11
|
+
return ++counter;
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const result1 = execute();
|
|
16
|
+
const result2 = execute();
|
|
17
|
+
expect(await result1).toStrictEqual(1);
|
|
18
|
+
expect(await result2).toStrictEqual(1);
|
|
19
|
+
});
|
package/src/sync/once.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { makePromise } from "./util";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An async event wrapper that ensures an async initialization is only ran once.
|
|
5
|
+
* Any subsequent calls after the first call will return a promise that resolves/rejects
|
|
6
|
+
* with the result of the first call.
|
|
7
|
+
*
|
|
8
|
+
* ## Example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { once } from "@pistonite/pure/sync";
|
|
11
|
+
*
|
|
12
|
+
* const getLuckyNumber = once({
|
|
13
|
+
* fn: async () => {
|
|
14
|
+
* console.log("running expensive initialization...")
|
|
15
|
+
* await new Promise((resolve) => setTimeout(resolve, 100));
|
|
16
|
+
* console.log("done")
|
|
17
|
+
* return 42;
|
|
18
|
+
* }
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* const result1 = getLuckyNumber();
|
|
22
|
+
* const result2 = getLuckyNumber();
|
|
23
|
+
* console.log(await result1);
|
|
24
|
+
* console.log(await result2);
|
|
25
|
+
* // logs:
|
|
26
|
+
* // running expensive initialization...
|
|
27
|
+
* // done
|
|
28
|
+
* // 42
|
|
29
|
+
* // 42
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* ## Caveat with HMR
|
|
33
|
+
* Some initialization might require clean up, such as unregister
|
|
34
|
+
* event handlers and/or timers. In this case, a production build might
|
|
35
|
+
* work fine but a HMR (Hot Module Reload) development server might not
|
|
36
|
+
* do this for you automatically.
|
|
37
|
+
*
|
|
38
|
+
* One way to work around this during development is to store the cleanup
|
|
39
|
+
* as a global object
|
|
40
|
+
* ```typescript
|
|
41
|
+
* const getResourceThatNeedsCleanup = once({
|
|
42
|
+
* fn: async () => {
|
|
43
|
+
* if (__DEV__) { // Configure your bundler to inject this
|
|
44
|
+
* // await if you need async clean up
|
|
45
|
+
* await (window as any).cleanupMyResource?.();
|
|
46
|
+
* }
|
|
47
|
+
*
|
|
48
|
+
* let resource: MyResource;
|
|
49
|
+
* if (__DEV__) {
|
|
50
|
+
* (window as any).cleanupMyResource = async () => {
|
|
51
|
+
* await resource?.cleanup();
|
|
52
|
+
* };
|
|
53
|
+
* }
|
|
54
|
+
*
|
|
55
|
+
* resource = await initResource();
|
|
56
|
+
* return resource;
|
|
57
|
+
* }
|
|
58
|
+
* });
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* An alternative solution is to not use `once` but instead tie the initialization
|
|
62
|
+
* of the resource to some other lifecycle event that gets cleaned up during HMR.
|
|
63
|
+
* For example, A framework that supports HMR for React components might unmount
|
|
64
|
+
* the component before reloading, which gives you a chance to clean up the resource.
|
|
65
|
+
*
|
|
66
|
+
* This is not an issue if the resource doesn't leak other resources,
|
|
67
|
+
* since it will eventually be GC'd.
|
|
68
|
+
*/
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
70
|
+
export function once<TFn extends (...args: any[]) => any>({
|
|
71
|
+
fn,
|
|
72
|
+
}: OnceConstructor<TFn>) {
|
|
73
|
+
const impl = new OnceImpl(fn);
|
|
74
|
+
return (...args: Parameters<TFn>): Promise<Awaited<ReturnType<TFn>>> =>
|
|
75
|
+
impl.invoke(...args);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type OnceConstructor<TFn> = {
|
|
79
|
+
/** Function to be called only once */
|
|
80
|
+
fn: TFn;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
84
|
+
export class OnceImpl<TFn extends (...args: any[]) => any> {
|
|
85
|
+
private promise: Promise<Awaited<ReturnType<TFn>>> | undefined;
|
|
86
|
+
|
|
87
|
+
constructor(private fn: TFn) {
|
|
88
|
+
this.fn = fn;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public async invoke(
|
|
92
|
+
...args: Parameters<TFn>
|
|
93
|
+
): Promise<Awaited<ReturnType<TFn>>> {
|
|
94
|
+
if (this.promise) {
|
|
95
|
+
return this.promise;
|
|
96
|
+
}
|
|
97
|
+
const { promise, resolve, reject } =
|
|
98
|
+
makePromise<Awaited<ReturnType<TFn>>>();
|
|
99
|
+
this.promise = promise;
|
|
100
|
+
try {
|
|
101
|
+
const result = await this.fn(...args);
|
|
102
|
+
resolve(result);
|
|
103
|
+
return result;
|
|
104
|
+
} catch (e) {
|
|
105
|
+
reject(e);
|
|
106
|
+
throw e;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/sync/persist.ts
CHANGED
|
@@ -88,7 +88,9 @@ class PersistImpl<T> implements Persist<T> {
|
|
|
88
88
|
value = loadedValue;
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
-
} catch {
|
|
91
|
+
} catch {
|
|
92
|
+
/* ignore */
|
|
93
|
+
}
|
|
92
94
|
this.cell.set(value);
|
|
93
95
|
this.unsubscribe = this.cell.subscribe((value) => {
|
|
94
96
|
this.storage.setItem(this.key, serialize(value));
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { test, expect, expectTypeOf, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { serial, type SerialCancelToken } from "./serial.ts";
|
|
4
|
+
import type { Result } from "../result/index.ts";
|
|
5
|
+
|
|
6
|
+
test("example", async () => {
|
|
7
|
+
// helper function to simulate async work
|
|
8
|
+
const wait = (ms: number) =>
|
|
9
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
// Create the wrapped function
|
|
11
|
+
const doWork = serial({
|
|
12
|
+
fn: (checkCancel) => async () => {
|
|
13
|
+
// this takes 1 seconds to complete
|
|
14
|
+
for (let i = 0; i < 10; i++) {
|
|
15
|
+
await wait(1);
|
|
16
|
+
checkCancel();
|
|
17
|
+
}
|
|
18
|
+
return 42;
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// The cancellation mechanism uses the Result type
|
|
23
|
+
// and returns an error when it is cancelled
|
|
24
|
+
const promise1 = doWork();
|
|
25
|
+
await wait(3);
|
|
26
|
+
// calling event.run a second time will cause `shouldCancel` to return false
|
|
27
|
+
// the next time it's called by the first event
|
|
28
|
+
const promise2 = doWork();
|
|
29
|
+
|
|
30
|
+
expect(await promise1).toStrictEqual({ err: "cancel" });
|
|
31
|
+
expect(await promise2).toStrictEqual({ val: 42 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("passing in arguments", async () => {
|
|
35
|
+
const execute = serial({
|
|
36
|
+
fn: (_) => async (arg1: number, arg2: string) => {
|
|
37
|
+
expect(arg1).toStrictEqual(42);
|
|
38
|
+
expect(arg2).toStrictEqual("hello");
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expectTypeOf(execute).toEqualTypeOf<
|
|
43
|
+
(arg1: number, arg2: string) => Promise<Result<void, SerialCancelToken>>
|
|
44
|
+
>();
|
|
45
|
+
|
|
46
|
+
await execute(42, "hello"); // no type error!
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("current serial number", async () => {
|
|
50
|
+
const execute = serial({
|
|
51
|
+
fn: (_, serial) => () => serial,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const one = await execute();
|
|
55
|
+
expect(one).toStrictEqual({ val: 1n });
|
|
56
|
+
const two = await execute();
|
|
57
|
+
expect(two).toStrictEqual({ val: 2n });
|
|
58
|
+
});
|
|
59
|
+
test("no manual cancel check", async () => {
|
|
60
|
+
const execute = serial({
|
|
61
|
+
fn: () => async () => {
|
|
62
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
63
|
+
return 42;
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const promise1 = execute();
|
|
67
|
+
const promise2 = execute();
|
|
68
|
+
expect(await promise1).toStrictEqual({ err: "cancel" });
|
|
69
|
+
expect(await promise2).toStrictEqual({ val: 42 });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("cancel callback", async () => {
|
|
73
|
+
const onCancel = vi.fn();
|
|
74
|
+
const execute = serial({
|
|
75
|
+
fn: (checkCancel) => async () => {
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
77
|
+
checkCancel();
|
|
78
|
+
return 42;
|
|
79
|
+
},
|
|
80
|
+
onCancel,
|
|
81
|
+
});
|
|
82
|
+
await Promise.all([execute(), execute()]);
|
|
83
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("cancel callback, no manual check", async () => {
|
|
87
|
+
const onCancel = vi.fn();
|
|
88
|
+
const execute = serial({
|
|
89
|
+
fn: () => async () => {
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
91
|
+
return 42;
|
|
92
|
+
},
|
|
93
|
+
onCancel,
|
|
94
|
+
});
|
|
95
|
+
await Promise.all([execute(), execute()]);
|
|
96
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
97
|
+
});
|
package/src/sync/serial.ts
CHANGED
|
@@ -132,6 +132,7 @@ import type { Result } from "../result/index.ts";
|
|
|
132
132
|
* If the underlying function throws, the exception will be re-thrown to the caller.
|
|
133
133
|
*/
|
|
134
134
|
|
|
135
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
135
136
|
export function serial<TFn extends (...args: any[]) => any>({
|
|
136
137
|
fn,
|
|
137
138
|
onCancel,
|
|
@@ -156,6 +157,7 @@ export type SerialConstructor<TFn> = {
|
|
|
156
157
|
onCancel?: SerialEventCancelCallback;
|
|
157
158
|
};
|
|
158
159
|
|
|
160
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
159
161
|
class SerialImpl<TFn extends (...args: any[]) => any> {
|
|
160
162
|
private serial: SerialId;
|
|
161
163
|
private fn: SerialFnCreator<TFn>;
|
package/src/sync/util.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
export const makePromise = <T>()
|
|
1
|
+
export const makePromise = <T>(): {
|
|
2
|
+
promise: Promise<T>;
|
|
3
|
+
resolve: (value: T | PromiseLike<T>) => void;
|
|
4
|
+
reject: (reason?: unknown) => void;
|
|
5
|
+
} => {
|
|
2
6
|
let resolve;
|
|
3
7
|
let reject;
|
|
4
8
|
const promise = new Promise<T>((res, rej) => {
|
|
@@ -18,6 +22,7 @@ export const makePromise = <T>() => {
|
|
|
18
22
|
};
|
|
19
23
|
|
|
20
24
|
/** Shorthand for Awaited<ReturnType<T>> */
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
26
|
export type AwaitRet<T> = T extends (...args: any[]) => infer R
|
|
22
27
|
? Awaited<R>
|
|
23
28
|
: never;
|