@pistonite/pure 0.0.18 → 0.0.20
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 +12 -10
- package/src/sync/debounce.test.ts +123 -0
- package/src/sync/debounce.ts +9 -7
- package/src/sync/index.ts +10 -2
- package/src/sync/latest.test.ts +64 -0
- package/src/sync/latest.ts +96 -24
- package/src/sync/once.test.ts +19 -0
- package/src/sync/once.ts +7 -13
- package/src/sync/persist.ts +3 -1
- package/src/sync/serial.test.ts +97 -0
- package/src/sync/serial.ts +3 -2
- package/src/sync/util.ts +8 -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.20",
|
|
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": "^3.0.5",
|
|
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
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type AnyFn,
|
|
3
|
+
makePromise,
|
|
4
|
+
type PromiseHandle,
|
|
5
|
+
type AwaitRet,
|
|
6
|
+
} from "./util.ts";
|
|
2
7
|
|
|
3
8
|
/**
|
|
4
9
|
* An async event wrapper that allows multiple calls in an interval
|
|
@@ -87,7 +92,7 @@ import { makePromise, type AwaitRet } from "./util.ts";
|
|
|
87
92
|
* ```
|
|
88
93
|
*
|
|
89
94
|
*/
|
|
90
|
-
export function batch<TFn extends
|
|
95
|
+
export function batch<TFn extends AnyFn>({
|
|
91
96
|
fn,
|
|
92
97
|
batch,
|
|
93
98
|
unbatch,
|
|
@@ -107,7 +112,7 @@ export function batch<TFn extends (...args: any[]) => any>({
|
|
|
107
112
|
/**
|
|
108
113
|
* Options to construct a `batch` function
|
|
109
114
|
*/
|
|
110
|
-
export type BatchConstructor<TFn extends
|
|
115
|
+
export type BatchConstructor<TFn extends AnyFn> = {
|
|
111
116
|
/** Function to be wrapped */
|
|
112
117
|
fn: TFn;
|
|
113
118
|
/** Function to batch the inputs across multiple calls */
|
|
@@ -133,14 +138,11 @@ export type BatchConstructor<TFn extends (...args: any[]) => any> = {
|
|
|
133
138
|
disregardExecutionTime?: boolean;
|
|
134
139
|
};
|
|
135
140
|
|
|
136
|
-
class BatchImpl<TFn extends
|
|
141
|
+
class BatchImpl<TFn extends AnyFn> {
|
|
137
142
|
private idle: boolean;
|
|
138
|
-
private scheduled: {
|
|
143
|
+
private scheduled: (PromiseHandle<AwaitRet<TFn>> & {
|
|
139
144
|
input: Parameters<TFn>;
|
|
140
|
-
|
|
141
|
-
resolve: (value: AwaitRet<TFn>) => void;
|
|
142
|
-
reject: (error: unknown) => void;
|
|
143
|
-
}[];
|
|
145
|
+
})[];
|
|
144
146
|
|
|
145
147
|
constructor(
|
|
146
148
|
private fn: TFn,
|
|
@@ -163,7 +165,7 @@ class BatchImpl<TFn extends (...args: any[]) => any> {
|
|
|
163
165
|
this.idle = false;
|
|
164
166
|
return this.execute(...args);
|
|
165
167
|
}
|
|
166
|
-
const { promise,
|
|
168
|
+
const { promise, resolve, reject } = makePromise<AwaitRet<TFn>>();
|
|
167
169
|
this.scheduled.push({
|
|
168
170
|
input: args,
|
|
169
171
|
promise,
|
|
@@ -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
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type AnyFn,
|
|
3
|
+
type AwaitRet,
|
|
4
|
+
makePromise,
|
|
5
|
+
type PromiseHandle,
|
|
6
|
+
} from "./util.ts";
|
|
2
7
|
|
|
3
8
|
/**
|
|
4
9
|
* An async event wrapper that is guaranteed to:
|
|
@@ -78,7 +83,7 @@ import { type AwaitRet, makePromise } from "./util.ts";
|
|
|
78
83
|
* });
|
|
79
84
|
* ```
|
|
80
85
|
*/
|
|
81
|
-
export function debounce<TFn extends
|
|
86
|
+
export function debounce<TFn extends AnyFn>({
|
|
82
87
|
fn,
|
|
83
88
|
interval,
|
|
84
89
|
disregardExecutionTime,
|
|
@@ -113,13 +118,10 @@ export type DebounceConstructor<TFn> = {
|
|
|
113
118
|
disregardExecutionTime?: boolean;
|
|
114
119
|
};
|
|
115
120
|
|
|
116
|
-
class DebounceImpl<TFn extends
|
|
121
|
+
class DebounceImpl<TFn extends AnyFn> {
|
|
117
122
|
private idle: boolean;
|
|
118
|
-
private next?: {
|
|
123
|
+
private next?: PromiseHandle<AwaitRet<TFn>> & {
|
|
119
124
|
args: Parameters<TFn>;
|
|
120
|
-
promise: Promise<AwaitRet<TFn>>;
|
|
121
|
-
resolve: (result: AwaitRet<TFn>) => void;
|
|
122
|
-
reject: (error: any) => void;
|
|
123
125
|
};
|
|
124
126
|
constructor(
|
|
125
127
|
private fn: TFn,
|
package/src/sync/index.ts
CHANGED
|
@@ -4,13 +4,21 @@
|
|
|
4
4
|
*
|
|
5
5
|
* @module
|
|
6
6
|
*/
|
|
7
|
-
export {
|
|
8
|
-
|
|
7
|
+
export {
|
|
8
|
+
serial,
|
|
9
|
+
type SerialConstructor,
|
|
10
|
+
type SerialEventCancelCallback,
|
|
11
|
+
type SerialCancelToken,
|
|
12
|
+
} from "./serial.ts";
|
|
13
|
+
export { latest, type LatestConstructor, type UpdateArgsFn } from "./latest.ts";
|
|
9
14
|
export { debounce, type DebounceConstructor } from "./debounce.ts";
|
|
10
15
|
export { batch, type BatchConstructor } from "./batch.ts";
|
|
11
16
|
export { cell, type CellConstructor, type Cell } from "./cell.ts";
|
|
12
17
|
export { persist, type PersistConstructor, type Persist } from "./persist.ts";
|
|
13
18
|
export { once, type OnceConstructor } from "./once.ts";
|
|
14
19
|
|
|
20
|
+
// types
|
|
21
|
+
export type { AnyFn, AwaitRet } from "./util.ts";
|
|
22
|
+
|
|
15
23
|
// unstable
|
|
16
24
|
export { RwLock } from "./RwLock.ts";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { expect, test, describe } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { latest } from "./latest.ts";
|
|
4
|
+
|
|
5
|
+
describe("latest", () => {
|
|
6
|
+
test("returns latest result", async () => {
|
|
7
|
+
let counter = 0;
|
|
8
|
+
|
|
9
|
+
const execute = latest({
|
|
10
|
+
fn: () => {
|
|
11
|
+
return ++counter;
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const result1 = execute();
|
|
16
|
+
const result2 = execute();
|
|
17
|
+
expect(await result1).toStrictEqual(2);
|
|
18
|
+
expect(await result2).toStrictEqual(2);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("uses are args equal, args are equal", async () => {
|
|
22
|
+
let counter = 0;
|
|
23
|
+
|
|
24
|
+
const execute = latest({
|
|
25
|
+
fn: async (name: string) => {
|
|
26
|
+
++counter;
|
|
27
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
28
|
+
return "hello " + name;
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
areArgsEqual: ([nameA], [nameB]) => nameA === nameB,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const result1 = execute("foo");
|
|
35
|
+
const result2 = execute("foo");
|
|
36
|
+
// vi.advanceTimersByTime(50);
|
|
37
|
+
expect(await result1).toStrictEqual("hello foo");
|
|
38
|
+
expect(await result2).toStrictEqual("hello foo");
|
|
39
|
+
// should only be called once
|
|
40
|
+
expect(counter).toEqual(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// this test doesn't pass with fake timers for some reason
|
|
44
|
+
test("uses are args equal, args are not equal", async () => {
|
|
45
|
+
let counter = 0;
|
|
46
|
+
|
|
47
|
+
const execute = latest({
|
|
48
|
+
fn: async (name: string) => {
|
|
49
|
+
++counter;
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
51
|
+
return "hello " + name;
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
areArgsEqual: ([nameA], [nameB]) => nameA === nameB,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const result1 = execute("foo");
|
|
58
|
+
const result2 = execute("bar");
|
|
59
|
+
// returns latest - both resolved at same time
|
|
60
|
+
expect(await result1).toStrictEqual("hello bar");
|
|
61
|
+
expect(await result2).toStrictEqual("hello bar");
|
|
62
|
+
expect(counter).toEqual(2);
|
|
63
|
+
});
|
|
64
|
+
});
|
package/src/sync/latest.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type AnyFn,
|
|
3
|
+
type AwaitRet,
|
|
4
|
+
makePromise,
|
|
5
|
+
type PromiseHandle,
|
|
6
|
+
} from "./util.ts";
|
|
2
7
|
|
|
3
8
|
/**
|
|
4
9
|
* An async event wrapper that always resolve to the result of the latest
|
|
@@ -26,52 +31,119 @@ import { makePromise } from "./util.ts";
|
|
|
26
31
|
* console.log(await result1); // 2
|
|
27
32
|
* console.log(await result2); // 2
|
|
28
33
|
* ```
|
|
34
|
+
*
|
|
35
|
+
* ## Advanced Usage
|
|
36
|
+
* See the constructor options for more advanced usage, for example,
|
|
37
|
+
* control how arguments are updated when new calls are made.
|
|
29
38
|
*/
|
|
30
|
-
export function latest<TFn extends
|
|
39
|
+
export function latest<TFn extends AnyFn>({
|
|
31
40
|
fn,
|
|
41
|
+
areArgsEqual,
|
|
42
|
+
updateArgs,
|
|
32
43
|
}: LatestConstructor<TFn>) {
|
|
33
|
-
const impl = new LatestImpl(fn);
|
|
44
|
+
const impl = new LatestImpl(fn, areArgsEqual, updateArgs);
|
|
34
45
|
return (...args: Parameters<TFn>) => impl.invoke(...args);
|
|
35
46
|
}
|
|
36
47
|
|
|
37
|
-
export type LatestConstructor<TFn> = {
|
|
48
|
+
export type LatestConstructor<TFn extends AnyFn> = {
|
|
38
49
|
/** Function to be wrapped */
|
|
39
50
|
fn: TFn;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Optional function to compare if arguments of 2 calls are equal.
|
|
54
|
+
*
|
|
55
|
+
* By default, separate calls are considered different, and the result
|
|
56
|
+
* of the latest call will be returned. However, if the function is pure,
|
|
57
|
+
* and the argument of a new call is the same as the call being executed,
|
|
58
|
+
* then the result of the call being executed will be returned. In other words,
|
|
59
|
+
* the new call will not result in another execution of the function.
|
|
60
|
+
*/
|
|
61
|
+
areArgsEqual?: (a: Parameters<TFn>, b: Parameters<TFn>) => boolean;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Optional function to update the arguments.
|
|
65
|
+
*
|
|
66
|
+
* By default, when new calls are made while the previous call is being executed,
|
|
67
|
+
* The function will be executed again with the latest arguments. This function
|
|
68
|
+
* is used to change this behavior and is called when new calls are made. In other words,
|
|
69
|
+
* the default value for this function is `(_current, _middle, latest) => latest`.
|
|
70
|
+
*
|
|
71
|
+
* The arguments are:
|
|
72
|
+
* - `current`: The arguments of the call currently being executed
|
|
73
|
+
* - `latest`: The argument of this new call
|
|
74
|
+
* - `middle`: If more than one call is made while the previous call is being executed,
|
|
75
|
+
* this array contains arguments of the calls between `current` and `latest`
|
|
76
|
+
* - `next`: This is the returned value of the previous call to updateArgs, i.e. the args
|
|
77
|
+
* to be executed next.
|
|
78
|
+
*
|
|
79
|
+
*/
|
|
80
|
+
updateArgs?: UpdateArgsFn<TFn>;
|
|
40
81
|
};
|
|
41
|
-
export
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
82
|
+
export type UpdateArgsFn<TFn extends AnyFn> = (
|
|
83
|
+
current: Parameters<TFn>,
|
|
84
|
+
middle: Parameters<TFn>[],
|
|
85
|
+
latest: Parameters<TFn>,
|
|
86
|
+
next: Parameters<TFn> | undefined,
|
|
87
|
+
) => Parameters<TFn>;
|
|
88
|
+
export class LatestImpl<TFn extends AnyFn> {
|
|
89
|
+
private pending?: PromiseHandle<AwaitRet<TFn>>;
|
|
90
|
+
|
|
91
|
+
/** current arguments. undefined means no current call */
|
|
92
|
+
private currentArgs?: Parameters<TFn>;
|
|
93
|
+
/** next arguments. undefined means no newer call */
|
|
94
|
+
private nextArgs?: Parameters<TFn>;
|
|
95
|
+
|
|
96
|
+
private middleArgs: Parameters<TFn>[];
|
|
48
97
|
|
|
49
|
-
|
|
50
|
-
|
|
98
|
+
private areArgsEqual: (a: Parameters<TFn>, b: Parameters<TFn>) => boolean;
|
|
99
|
+
private updateArgs: UpdateArgsFn<TFn>;
|
|
100
|
+
|
|
101
|
+
constructor(
|
|
102
|
+
private fn: TFn,
|
|
103
|
+
areArgsEqual?: (a: Parameters<TFn>, b: Parameters<TFn>) => boolean,
|
|
104
|
+
updateArgs?: UpdateArgsFn<TFn>,
|
|
105
|
+
) {
|
|
106
|
+
this.middleArgs = [];
|
|
107
|
+
this.areArgsEqual = areArgsEqual || (() => false);
|
|
108
|
+
this.updateArgs = updateArgs || ((_current, _middle, latest) => latest);
|
|
51
109
|
}
|
|
52
110
|
|
|
53
|
-
public async invoke(
|
|
54
|
-
...args: Parameters<TFn>
|
|
55
|
-
): Promise<Awaited<ReturnType<TFn>>> {
|
|
111
|
+
public async invoke(...args: Parameters<TFn>): Promise<AwaitRet<TFn>> {
|
|
56
112
|
if (this.pending) {
|
|
57
|
-
|
|
113
|
+
// pending means currentArgs is not undefined
|
|
114
|
+
const currentArgs = this.currentArgs as Parameters<TFn>;
|
|
115
|
+
const nextArgs = this.updateArgs(
|
|
116
|
+
currentArgs,
|
|
117
|
+
this.middleArgs,
|
|
118
|
+
args,
|
|
119
|
+
this.nextArgs,
|
|
120
|
+
);
|
|
121
|
+
if (this.areArgsEqual(nextArgs, currentArgs)) {
|
|
122
|
+
// do not schedule new call
|
|
123
|
+
this.nextArgs = undefined;
|
|
124
|
+
} else {
|
|
125
|
+
this.nextArgs = nextArgs;
|
|
126
|
+
}
|
|
58
127
|
return this.pending.promise;
|
|
59
128
|
}
|
|
60
|
-
|
|
129
|
+
|
|
130
|
+
// assign to next args to make the loop cleaner
|
|
131
|
+
this.nextArgs = args;
|
|
132
|
+
|
|
133
|
+
this.pending = makePromise();
|
|
61
134
|
let error = undefined;
|
|
62
135
|
let result;
|
|
63
|
-
while (
|
|
64
|
-
this.
|
|
136
|
+
while (this.nextArgs) {
|
|
137
|
+
this.currentArgs = this.nextArgs;
|
|
138
|
+
this.nextArgs = undefined;
|
|
65
139
|
try {
|
|
66
140
|
const fn = this.fn;
|
|
67
|
-
result = await fn(...
|
|
141
|
+
result = await fn(...this.currentArgs);
|
|
68
142
|
} catch (e) {
|
|
69
143
|
error = e;
|
|
70
144
|
}
|
|
71
|
-
if (!this.hasNewer) {
|
|
72
|
-
break;
|
|
73
|
-
}
|
|
74
145
|
}
|
|
146
|
+
this.currentArgs = undefined;
|
|
75
147
|
const pending = this.pending;
|
|
76
148
|
this.pending = undefined;
|
|
77
149
|
if (error) {
|
|
@@ -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
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { makePromise } from "./util";
|
|
1
|
+
import { type AnyFn, type AwaitRet, makePromise } from "./util.ts";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* An async event wrapper that ensures an async initialization is only ran once.
|
|
@@ -66,12 +66,9 @@ import { makePromise } from "./util";
|
|
|
66
66
|
* This is not an issue if the resource doesn't leak other resources,
|
|
67
67
|
* since it will eventually be GC'd.
|
|
68
68
|
*/
|
|
69
|
-
export function once<TFn extends (
|
|
70
|
-
fn,
|
|
71
|
-
}: OnceConstructor<TFn>) {
|
|
69
|
+
export function once<TFn extends AnyFn>({ fn }: OnceConstructor<TFn>) {
|
|
72
70
|
const impl = new OnceImpl(fn);
|
|
73
|
-
return (...args: Parameters<TFn>)
|
|
74
|
-
impl.invoke(...args);
|
|
71
|
+
return (...args: Parameters<TFn>) => impl.invoke(...args);
|
|
75
72
|
}
|
|
76
73
|
|
|
77
74
|
export type OnceConstructor<TFn> = {
|
|
@@ -79,21 +76,18 @@ export type OnceConstructor<TFn> = {
|
|
|
79
76
|
fn: TFn;
|
|
80
77
|
};
|
|
81
78
|
|
|
82
|
-
export class OnceImpl<TFn extends
|
|
83
|
-
private promise: Promise<
|
|
79
|
+
export class OnceImpl<TFn extends AnyFn> {
|
|
80
|
+
private promise: Promise<AwaitRet<TFn>> | undefined;
|
|
84
81
|
|
|
85
82
|
constructor(private fn: TFn) {
|
|
86
83
|
this.fn = fn;
|
|
87
84
|
}
|
|
88
85
|
|
|
89
|
-
public async invoke(
|
|
90
|
-
...args: Parameters<TFn>
|
|
91
|
-
): Promise<Awaited<ReturnType<TFn>>> {
|
|
86
|
+
public async invoke(...args: Parameters<TFn>): Promise<AwaitRet<TFn>> {
|
|
92
87
|
if (this.promise) {
|
|
93
88
|
return this.promise;
|
|
94
89
|
}
|
|
95
|
-
const { promise, resolve, reject } =
|
|
96
|
-
makePromise<Awaited<ReturnType<TFn>>>();
|
|
90
|
+
const { promise, resolve, reject } = makePromise<AwaitRet<TFn>>();
|
|
97
91
|
this.promise = promise;
|
|
98
92
|
try {
|
|
99
93
|
const result = await this.fn(...args);
|
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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Result } from "../result/index.ts";
|
|
2
|
+
import type { AnyFn } from "./util.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* An async event wrapper that is cancelled when a new one starts.
|
|
@@ -132,7 +133,7 @@ import type { Result } from "../result/index.ts";
|
|
|
132
133
|
* If the underlying function throws, the exception will be re-thrown to the caller.
|
|
133
134
|
*/
|
|
134
135
|
|
|
135
|
-
export function serial<TFn extends
|
|
136
|
+
export function serial<TFn extends AnyFn>({
|
|
136
137
|
fn,
|
|
137
138
|
onCancel,
|
|
138
139
|
}: SerialConstructor<TFn>) {
|
|
@@ -156,7 +157,7 @@ export type SerialConstructor<TFn> = {
|
|
|
156
157
|
onCancel?: SerialEventCancelCallback;
|
|
157
158
|
};
|
|
158
159
|
|
|
159
|
-
class SerialImpl<TFn extends
|
|
160
|
+
class SerialImpl<TFn extends AnyFn> {
|
|
160
161
|
private serial: SerialId;
|
|
161
162
|
private fn: SerialFnCreator<TFn>;
|
|
162
163
|
private onCancel: SerialEventCancelCallback;
|
package/src/sync/util.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const makePromise = <T>(): {
|
|
2
2
|
promise: Promise<T>;
|
|
3
3
|
resolve: (value: T | PromiseLike<T>) => void;
|
|
4
|
-
reject: (reason?:
|
|
4
|
+
reject: (reason?: unknown) => void;
|
|
5
5
|
} => {
|
|
6
6
|
let resolve;
|
|
7
7
|
let reject;
|
|
@@ -21,7 +21,14 @@ export const makePromise = <T>(): {
|
|
|
21
21
|
};
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
export type PromiseHandle<T> = ReturnType<typeof makePromise<T>>;
|
|
25
|
+
|
|
24
26
|
/** Shorthand for Awaited<ReturnType<T>> */
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
28
|
export type AwaitRet<T> = T extends (...args: any[]) => infer R
|
|
26
29
|
? Awaited<R>
|
|
27
30
|
: never;
|
|
31
|
+
|
|
32
|
+
/** Type for any function */
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
export type AnyFn = (...args: any[]) => any;
|