@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 CHANGED
@@ -1,35 +1,39 @@
1
1
  {
2
- "name": "@pistonite/pure",
3
- "version": "0.0.17",
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.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
+ }
@@ -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
@@ -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
+ });
@@ -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: any) => void;
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
+ });
@@ -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
+ });
@@ -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
+ }
@@ -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
+ });
@@ -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;