@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 CHANGED
@@ -1,35 +1,39 @@
1
1
  {
2
- "name": "@pistonite/pure",
3
- "version": "0.0.18",
4
- "description": "Pure TypeScript libraries for my projects",
5
- "homepage": "https://github.com/Pistonite/pure",
6
- "bugs": {
7
- "url": "https://github.com/Pistonite/pure/issues"
8
- },
9
- "license": "MIT",
10
- "author": "Pistonight <pistonknight@outlook.com>",
11
- "files": [
12
- "src/**/*",
13
- "!src/**/*.test.ts"
14
- ],
15
- "exports": {
16
- "./fs": "./src/fs/index.ts",
17
- "./log": "./src/log/index.ts",
18
- "./result": "./src/result/index.ts",
19
- "./sync": "./src/sync/index.ts",
20
- "./pref": "./src/pref/index.ts"
21
- },
22
- "repository": {
23
- "type": "git",
24
- "url": "git+https://github.com/Pistonite/pure.git",
25
- "directory": "packages/pure"
26
- },
27
- "dependencies": {
28
- "@types/file-saver": "^2.0.7",
29
- "denque": "2.1.0",
30
- "file-saver": "2.0.5"
31
- },
32
- "devDependencies": {
33
- "vitest": "^2.1.8"
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
+ }
@@ -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: { [path: string]: FsFile };
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[path];
14
+ let file = this.opened.get(path);
15
15
  if (!file) {
16
16
  file = fsFile(fs, path);
17
- this.opened[path] = file;
17
+ this.opened.set(path, file);
18
18
  }
19
19
  return file;
20
20
  }
21
21
 
22
22
  public close(path: string): void {
23
- delete this.opened[path];
23
+ this.opened.delete(path);
24
24
  }
25
25
 
26
26
  public getOpenedPaths(): string[] {
27
- return Object.keys(this.opened);
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
- * Dark mode wrappers
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
- let _dark = options.initial || prefersDarkMode();
93
+ const _dark = options.initial || prefersDarkMode();
102
94
 
103
95
  const selector = options.selector ?? ":root";
104
96
  if (selector) {
@@ -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
- /** Initialize locale global state */
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;
@@ -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 { makePromise, type AwaitRet } from "./util.ts";
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 (...args: any[]) => any>({
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 (...args: any[]) => any> = {
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 (...args: any[]) => any> {
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
- promise: Promise<AwaitRet<TFn>>;
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, reject, resolve } = makePromise<AwaitRet<TFn>>();
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
+ });
@@ -1,4 +1,9 @@
1
- import { type AwaitRet, makePromise } from "./util.ts";
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 (...args: any[]) => any>({
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 (...args: any[]) => any> {
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 { serial, type SerialConstructor } from "./serial.ts";
8
- export { latest, type LatestConstructor } from "./latest.ts";
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
+ });
@@ -1,4 +1,9 @@
1
- import { makePromise } from "./util.ts";
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 (...args: any[]) => any>({
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 class LatestImpl<TFn extends (...args: any[]) => any> {
42
- private hasNewer: boolean;
43
- private pending?: {
44
- promise: Promise<Awaited<ReturnType<TFn>>>;
45
- resolve: (result: Awaited<ReturnType<TFn>>) => void;
46
- reject: (error: unknown) => void;
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
- constructor(private fn: TFn) {
50
- this.hasNewer = false;
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
- this.hasNewer = true;
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
- this.pending = makePromise<Awaited<ReturnType<TFn>>>();
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 (true) {
64
- this.hasNewer = false;
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(...args);
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 (...args: any[]) => any>({
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>): Promise<Awaited<ReturnType<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 (...args: any[]) => any> {
83
- private promise: Promise<Awaited<ReturnType<TFn>>> | undefined;
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);
@@ -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
+ });
@@ -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 (...args: any[]) => any>({
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 (...args: any[]) => any> {
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?: any) => void;
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;