@dxos/async 0.8.4-main.f9ba587 → 0.8.4-main.fcc0d83b33

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.
Files changed (77) hide show
  1. package/dist/lib/browser/index.mjs +306 -269
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +306 -269
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/callback.d.ts +2 -1
  8. package/dist/types/src/callback.d.ts.map +1 -1
  9. package/dist/types/src/chain.d.ts +1 -1
  10. package/dist/types/src/chain.d.ts.map +1 -1
  11. package/dist/types/src/cleanup.d.ts +3 -3
  12. package/dist/types/src/cleanup.d.ts.map +1 -1
  13. package/dist/types/src/debounce.d.ts +37 -3
  14. package/dist/types/src/debounce.d.ts.map +1 -1
  15. package/dist/types/src/errors.d.ts.map +1 -1
  16. package/dist/types/src/event-emitter.d.ts.map +1 -1
  17. package/dist/types/src/events.d.ts.map +1 -1
  18. package/dist/types/src/index.d.ts +0 -5
  19. package/dist/types/src/index.d.ts.map +1 -1
  20. package/dist/types/src/mutex.d.ts.map +1 -1
  21. package/dist/types/src/observable-value.d.ts.map +1 -1
  22. package/dist/types/src/observable.d.ts.map +1 -1
  23. package/dist/types/src/persistent-lifecycle.d.ts +2 -2
  24. package/dist/types/src/persistent-lifecycle.d.ts.map +1 -1
  25. package/dist/types/src/stream-to-array.d.ts.map +1 -1
  26. package/dist/types/src/task-scheduling.d.ts +29 -1
  27. package/dist/types/src/task-scheduling.d.ts.map +1 -1
  28. package/dist/types/src/test-stream.d.ts.map +1 -1
  29. package/dist/types/src/testing.d.ts +13 -0
  30. package/dist/types/src/testing.d.ts.map +1 -1
  31. package/dist/types/src/timeout.d.ts +2 -2
  32. package/dist/types/src/timeout.d.ts.map +1 -1
  33. package/dist/types/src/timer.d.ts.map +1 -1
  34. package/dist/types/src/track-leaks.d.ts.map +1 -1
  35. package/dist/types/src/trigger.d.ts +11 -0
  36. package/dist/types/src/trigger.d.ts.map +1 -1
  37. package/dist/types/src/update-scheduler.d.ts.map +1 -1
  38. package/dist/types/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +13 -12
  40. package/src/callback.ts +3 -3
  41. package/src/chain.ts +1 -1
  42. package/src/cleanup.ts +10 -7
  43. package/src/debounce.test.ts +69 -12
  44. package/src/debounce.ts +88 -14
  45. package/src/event-emitter.test.ts +1 -1
  46. package/src/index.ts +0 -5
  47. package/src/observable-value.test.ts +1 -1
  48. package/src/observable-value.ts +4 -2
  49. package/src/persistent-lifecycle.test.ts +1 -1
  50. package/src/persistent-lifecycle.ts +2 -2
  51. package/src/task-scheduling.ts +95 -1
  52. package/src/testing.test.ts +41 -1
  53. package/src/testing.ts +53 -0
  54. package/src/timeout.ts +27 -29
  55. package/src/trigger.ts +58 -1
  56. package/src/update-scheduler.ts +1 -1
  57. package/dist/types/src/latch.d.ts +0 -11
  58. package/dist/types/src/latch.d.ts.map +0 -1
  59. package/dist/types/src/sink.d.ts +0 -6
  60. package/dist/types/src/sink.d.ts.map +0 -1
  61. package/dist/types/src/throttle.d.ts +0 -2
  62. package/dist/types/src/throttle.d.ts.map +0 -1
  63. package/dist/types/src/throttle.test.d.ts +0 -2
  64. package/dist/types/src/throttle.test.d.ts.map +0 -1
  65. package/dist/types/src/types.d.ts +0 -2
  66. package/dist/types/src/types.d.ts.map +0 -1
  67. package/dist/types/src/until.d.ts +0 -14
  68. package/dist/types/src/until.d.ts.map +0 -1
  69. package/dist/types/src/until.test.d.ts +0 -2
  70. package/dist/types/src/until.test.d.ts.map +0 -1
  71. package/src/latch.ts +0 -60
  72. package/src/sink.ts +0 -26
  73. package/src/throttle.test.ts +0 -65
  74. package/src/throttle.ts +0 -14
  75. package/src/types.ts +0 -5
  76. package/src/until.test.ts +0 -47
  77. package/src/until.ts +0 -58
package/package.json CHANGED
@@ -1,27 +1,28 @@
1
1
  {
2
2
  "name": "@dxos/async",
3
- "version": "0.8.4-main.f9ba587",
3
+ "version": "0.8.4-main.fcc0d83b33",
4
4
  "description": "Async utilities.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
7
11
  "license": "MIT",
8
12
  "author": "DXOS.org",
9
13
  "sideEffects": true,
10
14
  "type": "module",
11
15
  "exports": {
12
16
  ".": {
17
+ "types": "./dist/types/src/index.d.ts",
13
18
  "browser": "./dist/lib/browser/index.mjs",
14
19
  "node": {
15
20
  "require": "./dist/lib/node/index.cjs",
16
21
  "default": "./dist/lib/node-esm/index.mjs"
17
- },
18
- "types": "./dist/types/src/index.d.ts"
22
+ }
19
23
  }
20
24
  },
21
25
  "types": "dist/types/src/index.d.ts",
22
- "typesVersions": {
23
- "*": {}
24
- },
25
26
  "files": [
26
27
  "dist",
27
28
  "src"
@@ -29,12 +30,12 @@
29
30
  "dependencies": {
30
31
  "zen-observable": "^0.10.0",
31
32
  "zen-push": "^0.3.1",
32
- "@dxos/context": "0.8.4-main.f9ba587",
33
- "@dxos/debug": "0.8.4-main.f9ba587",
34
- "@dxos/invariant": "0.8.4-main.f9ba587",
35
- "@dxos/log": "0.8.4-main.f9ba587",
36
- "@dxos/node-std": "0.8.4-main.f9ba587",
37
- "@dxos/util": "0.8.4-main.f9ba587"
33
+ "@dxos/context": "0.8.4-main.fcc0d83b33",
34
+ "@dxos/debug": "0.8.4-main.fcc0d83b33",
35
+ "@dxos/invariant": "0.8.4-main.fcc0d83b33",
36
+ "@dxos/log": "0.8.4-main.fcc0d83b33",
37
+ "@dxos/node-std": "0.8.4-main.fcc0d83b33",
38
+ "@dxos/util": "0.8.4-main.fcc0d83b33"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@types/zen-observable": "^0.8.3"
package/src/callback.ts CHANGED
@@ -2,12 +2,12 @@
2
2
  // Copyright 2020 DXOS.org
3
3
  //
4
4
 
5
+ export type Awaited<T> = T extends Promise<infer U> ? U : T;
6
+
5
7
  /**
6
8
  * Helper to convert a callback based API into a promise based API.
7
9
  */
8
- export const createPromiseFromCallback = <T = void>(
9
- run: (cb: (error?: Error, value?: T) => void) => void,
10
- ): Promise<T> =>
10
+ export const promiseFromCallback = <T = void>(run: (cb: (error?: Error, value?: T) => void) => void): Promise<T> =>
11
11
  new Promise((resolve, reject) => {
12
12
  run((error, value) => {
13
13
  if (error) {
package/src/chain.ts CHANGED
@@ -7,7 +7,7 @@ type Transform = (...args: any) => Promise<any>;
7
7
  /**
8
8
  * Async reducer iteratively applies functions to the given array of elements.
9
9
  */
10
- export const asyncChain =
10
+ export const chain =
11
11
  <T>(chain: Transform[]) =>
12
12
  async (elements: Promise<T[]>) => {
13
13
  let result = await elements;
package/src/cleanup.ts CHANGED
@@ -10,9 +10,12 @@ export type CleanupFn = () => void;
10
10
  * Combine multiple cleanup functions into a single cleanup function.
11
11
  * Can be used in effect hooks in conjunction with `addEventListener`.
12
12
  */
13
- export const combine = (...cleanupFns: (CleanupFn | CleanupFn[])[]): CleanupFn => {
13
+ export const combine = (...cleanupFns: (boolean | undefined | CleanupFn | CleanupFn[])[]): CleanupFn => {
14
14
  return () => {
15
- cleanupFns.flat().forEach((cleanupFn) => cleanupFn());
15
+ cleanupFns
16
+ .flat()
17
+ .filter((f): f is CleanupFn => typeof f === 'function')
18
+ .forEach((cleanupFn) => cleanupFn());
16
19
  };
17
20
  };
18
21
 
@@ -38,21 +41,21 @@ type EventMap<T> = T extends Window
38
41
  * Add the event listener and return a cleanup function.
39
42
  * Can be used in effect hooks in conjunction with `combine`.
40
43
  */
41
- export function addEventListener<T extends EventTarget, K extends keyof EventMap<T>>(
44
+ export const addEventListener = <T extends EventTarget, K extends keyof EventMap<T>>(
42
45
  target: T,
43
46
  type: K,
44
47
  listener: (this: T, ev: EventMap<T>[K]) => any,
45
48
  options?: boolean | AddEventListenerOptions,
46
- ): CleanupFn {
49
+ ): CleanupFn => {
47
50
  target.addEventListener(type as string, listener as EventListener, options);
48
51
  return () => target.removeEventListener(type as string, listener as EventListener, options);
49
- }
52
+ };
50
53
 
51
54
  export class SubscriptionList {
52
55
  private readonly _cleanups: CleanupFn[] = [];
53
56
 
54
- add(cb: CleanupFn): this {
55
- this._cleanups.push(cb);
57
+ add(...cb: CleanupFn[]): this {
58
+ this._cleanups.push(...cb);
56
59
  return this;
57
60
  }
58
61
 
@@ -4,25 +4,82 @@
4
4
 
5
5
  import { describe, expect, test } from 'vitest';
6
6
 
7
- import { debounce } from './debounce';
7
+ import { debounce, throttle } from './debounce';
8
8
  import { sleep } from './timeout';
9
9
 
10
+ describe('throttle', () => {
11
+ test('throttles function calls', async () => {
12
+ let count = 0;
13
+ const fn = throttle(() => count++, 100);
14
+
15
+ // First call should execute immediately.
16
+ fn();
17
+ expect(count).toBe(1);
18
+
19
+ // Second call within throttle window should not execute.
20
+ fn();
21
+ expect(count).toBe(1);
22
+
23
+ // Wait for throttle window to pass.
24
+ await sleep(150);
25
+
26
+ // Next call should execute.
27
+ fn();
28
+ expect(count).toBe(2);
29
+ });
30
+
31
+ test('passes arguments to throttled function', async () => {
32
+ let lastArgs: any[] = [];
33
+ const fn = throttle((...args: any[]) => {
34
+ lastArgs = args;
35
+ }, 100);
36
+
37
+ fn('test', 123);
38
+ expect(lastArgs).toEqual(['test', 123]);
39
+
40
+ // Call with different args within throttle window.
41
+ fn('different', 456);
42
+ expect(lastArgs).toEqual(['test', 123]); // Should not update.
43
+
44
+ await sleep(150);
45
+ fn('new', 789);
46
+ expect(lastArgs).toEqual(['new', 789]);
47
+ });
48
+
49
+ test('handles multiple rapid calls', async () => {
50
+ let count = 0;
51
+ const fn = throttle(() => count++, 100);
52
+
53
+ // Make multiple rapid calls.
54
+ for (let i = 0; i < 5; i++) {
55
+ fn();
56
+ }
57
+ expect(count).toBe(1); // Only first call should execute.
58
+
59
+ await sleep(150);
60
+ expect(count).toBe(1); // Still only one execution.
61
+
62
+ fn();
63
+ expect(count).toBe(2); // Next call after wait should execute.
64
+ });
65
+ });
66
+
10
67
  describe('debounce', () => {
11
68
  test('debounces function calls', async () => {
12
69
  let count = 0;
13
70
  const fn = debounce(() => count++, 100);
14
71
 
15
- // First call should not execute immediately
72
+ // First call should not execute immediately.
16
73
  fn();
17
74
  expect(count).toBe(0);
18
75
 
19
- // Second call should reset the timer
76
+ // Second call should reset the timer.
20
77
  fn();
21
78
  expect(count).toBe(0);
22
79
 
23
- // Wait for debounce window to pass
80
+ // Wait for debounce window to pass.
24
81
  await sleep(150);
25
- expect(count).toBe(1); // Only the last call should execute
82
+ expect(count).toBe(1); // Only the last call should execute.
26
83
  });
27
84
 
28
85
  test('passes arguments to debounced function', async () => {
@@ -32,27 +89,27 @@ describe('debounce', () => {
32
89
  }, 100);
33
90
 
34
91
  fn('test', 123);
35
- expect(lastArgs).toEqual([]); // Should not execute immediately
92
+ expect(lastArgs).toEqual([]); // Should not execute immediately.
36
93
 
37
- // Call with different args
94
+ // Call with different args.
38
95
  fn('different', 456);
39
- expect(lastArgs).toEqual([]); // Should not execute immediately
96
+ expect(lastArgs).toEqual([]); // Should not execute immediately.
40
97
 
41
98
  await sleep(150);
42
- expect(lastArgs).toEqual(['different', 456]); // Should execute with last args
99
+ expect(lastArgs).toEqual(['different', 456]); // Should execute with last args.
43
100
  });
44
101
 
45
102
  test('handles multiple rapid calls', async () => {
46
103
  let count = 0;
47
104
  const fn = debounce(() => count++, 100);
48
105
 
49
- // Make multiple rapid calls
106
+ // Make multiple rapid calls.
50
107
  for (let i = 0; i < 5; i++) {
51
108
  fn();
52
109
  }
53
- expect(count).toBe(0); // Should not execute immediately
110
+ expect(count).toBe(0); // Should not execute immediately.
54
111
 
55
112
  await sleep(150);
56
- expect(count).toBe(1); // Should execute only once after wait
113
+ expect(count).toBe(1); // Should execute only once after wait.
57
114
  });
58
115
  });
package/src/debounce.ts CHANGED
@@ -2,28 +2,102 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { throttle } from './throttle';
5
+ type Callback = (...args: any[]) => void;
6
6
 
7
7
  /**
8
- * Debounce callback.
8
+ * Delay callback execution by a specified time.
9
+ * Unlike debounce, subsequent calls during the delay period are ignored.
10
+ *
11
+ * @param cb Callback to invoke.
12
+ * @param delay Time to wait before invoking the callback.
13
+ * @returns A new function that schedules the callback once and ignores subsequent calls until executed.
9
14
  */
10
- export const debounce = (cb: (...args: any[]) => void, wait = 100): ((...args: any[]) => void) => {
15
+ export const delay = <F extends Callback>(cb: F, delay = 100): F => {
16
+ let pending = false;
17
+ return ((...args: any[]) => {
18
+ if (pending) {
19
+ return;
20
+ }
21
+
22
+ pending = true;
23
+ setTimeout(() => {
24
+ try {
25
+ cb(...args);
26
+ } finally {
27
+ pending = false;
28
+ }
29
+ }, delay);
30
+ }) as F;
31
+ };
32
+
33
+ /**
34
+ * Debounce callback: delays execution until calls stop.
35
+ * Each new call resets the timer, so the callback fires only after the delay elapses with no further calls (trailing-edge).
36
+ * Use when you want to react to the end of a burst of events (e.g. user stops typing).
37
+ *
38
+ * @param cb Callback to invoke.
39
+ * @param delay Idle time (ms) to wait after the last call before invoking.
40
+ * @returns Wrapped function that postpones invocation until activity ceases.
41
+ */
42
+ export const debounce = <F extends Callback>(cb: F, delay = 100): F => {
11
43
  let t: ReturnType<typeof setTimeout>;
12
- return (...args: any[]) => {
44
+ return ((...args: any[]) => {
13
45
  clearTimeout(t);
14
- t = setTimeout(() => cb(...args), wait);
15
- };
46
+ t = setTimeout(() => cb(...args), delay);
47
+ }) as F;
48
+ };
49
+
50
+ /**
51
+ * Throttle callback: limits execution to at most once per interval.
52
+ * The callback fires immediately on the first call;
53
+ * subsequent calls within the same interval are dropped (leading-edge).
54
+ * Use when you need regular updates at a bounded rate (e.g. scroll or resize handlers).
55
+ *
56
+ * @param cb Callback to invoke.
57
+ * @param delay Minimum interval (ms) between successive invocations.
58
+ * @returns Wrapped function that rate-limits invocations.
59
+ */
60
+ export const throttle = <F extends Callback>(cb: F, delay = 100): F => {
61
+ let lastCall = 0;
62
+ return ((...args: any[]) => {
63
+ const now = Date.now();
64
+ if (now - lastCall >= delay) {
65
+ cb(...args);
66
+ lastCall = now;
67
+ }
68
+ }) as F;
16
69
  };
17
70
 
18
71
  /**
19
72
  * Debounce and throttle callback.
73
+ * Executes immediately on the first call (throttle), prevents calls during the throttle window,
74
+ * and ensures a final call happens after activity stops (debounce).
75
+ *
76
+ * @param cb Callback to invoke.
77
+ * @param delay Time window for both throttle and debounce.
78
+ * @returns A new function that combines throttle and debounce behavior.
20
79
  */
21
- export const debounceAndThrottle = (cb: (...args: any[]) => void, wait = 100): ((...args: any[]) => void) => {
22
- const debounced = debounce(cb, wait);
23
- const throttled = throttle(cb, wait);
24
-
25
- return (...args: any[]) => {
26
- debounced(...args);
27
- throttled(...args);
28
- };
80
+ export const debounceAndThrottle = <F extends Callback>(cb: F, delay = 100): F => {
81
+ let timeout: ReturnType<typeof setTimeout>;
82
+ let lastCall = 0;
83
+
84
+ return ((...args: any[]) => {
85
+ const now = Date.now();
86
+ const delta = now - lastCall;
87
+
88
+ // Clear any pending debounced call.
89
+ clearTimeout(timeout);
90
+
91
+ // Throttle: execute immediately if enough time has passed.
92
+ if (delta >= delay) {
93
+ cb(...args);
94
+ lastCall = now;
95
+ } else {
96
+ // Debounce: schedule to execute after the remaining time.
97
+ timeout = setTimeout(() => {
98
+ cb(...args);
99
+ lastCall = Date.now();
100
+ }, delay - delta);
101
+ }
102
+ }) as F;
29
103
  };
@@ -6,8 +6,8 @@ import { EventEmitter } from 'node:events';
6
6
  import { describe, expect, test } from 'vitest';
7
7
 
8
8
  import { onEvent, waitForEvent } from './event-emitter';
9
- import { latch } from './latch';
10
9
  import { asyncTimeout } from './timeout';
10
+ import { latch } from './trigger';
11
11
 
12
12
  describe('event-emitter', () => {
13
13
  test('onEvent', async () => {
package/src/index.ts CHANGED
@@ -9,22 +9,17 @@ export * from './debounce';
9
9
  export * from './errors';
10
10
  export * from './event-emitter';
11
11
  export * from './events';
12
- export * from './latch';
13
12
  export * from './mutex';
14
13
  export * from './observable';
15
14
  export * from './observable-value';
16
15
  export * from './persistent-lifecycle';
17
16
  export * from './push-iterable';
18
- export * from './sink';
19
17
  export * from './stream-to-array';
20
18
  export * from './task-scheduling';
21
19
  export * from './test-stream';
22
20
  export * from './testing';
23
- export * from './throttle';
24
21
  export * from './timeout';
25
22
  export * from './timer';
26
23
  export * from './track-leaks';
27
24
  export * from './trigger';
28
- export * from './types';
29
- export * from './until';
30
25
  export * from './update-scheduler';
@@ -5,12 +5,12 @@
5
5
  import { describe, expect, test } from 'vitest';
6
6
 
7
7
  import { type AsyncEvents, TimeoutError } from './errors';
8
- import { latch } from './latch';
9
8
  import {
10
9
  type CancellableObservable,
11
10
  type CancellableObservableEvents,
12
11
  CancellableObservableProvider,
13
12
  } from './observable-value';
13
+ import { latch } from './trigger';
14
14
 
15
15
  interface ConnectionEvents extends AsyncEvents, CancellableObservableEvents {
16
16
  onConnected(connectionId: string): void;
@@ -66,8 +66,10 @@ export interface CancellableObservableEvents {
66
66
  /**
67
67
  * @deprecated
68
68
  */
69
- export interface CancellableObservable<Events extends CancellableObservableEvents, Value = unknown>
70
- extends ObservableValue<Events, Value> {
69
+ export interface CancellableObservable<
70
+ Events extends CancellableObservableEvents,
71
+ Value = unknown,
72
+ > extends ObservableValue<Events, Value> {
71
73
  cancel(): Promise<void>;
72
74
  }
73
75
 
@@ -2,7 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { describe, expect, test, onTestFinished } from 'vitest';
5
+ import { describe, expect, onTestFinished, test } from 'vitest';
6
6
 
7
7
  import { log } from '@dxos/log';
8
8
 
@@ -13,7 +13,7 @@ import { sleep } from './timeout';
13
13
  const INIT_RESTART_DELAY = 100;
14
14
  const DEFAULT_MAX_RESTART_DELAY = 5000;
15
15
 
16
- export type PersistentLifecycleParams<T> = {
16
+ export type PersistentLifecycleProps<T> = {
17
17
  /**
18
18
  * Create connection.
19
19
  * If promise resolves successfully, connection is considered established.
@@ -51,7 +51,7 @@ export class PersistentLifecycle<T> extends Resource {
51
51
  private _restartTask?: DeferredTask = undefined;
52
52
  private _restartAfter = 0;
53
53
 
54
- constructor({ start, stop, onRestart, maxRestartDelay = DEFAULT_MAX_RESTART_DELAY }: PersistentLifecycleParams<T>) {
54
+ constructor({ start, stop, onRestart, maxRestartDelay = DEFAULT_MAX_RESTART_DELAY }: PersistentLifecycleProps<T>) {
55
55
  super();
56
56
  this._start = start;
57
57
  this._stop = stop;
@@ -2,7 +2,7 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
5
- import { ContextDisposedError, type Context } from '@dxos/context';
5
+ import { Context, ContextDisposedError } from '@dxos/context';
6
6
  import { StackTrace } from '@dxos/debug';
7
7
  import { type MaybePromise } from '@dxos/util';
8
8
 
@@ -77,6 +77,100 @@ export class DeferredTask {
77
77
  }
78
78
  }
79
79
 
80
+ // TODO(dmaretskyi): Protyping this an alternative API to DeferredTask.
81
+ export class AsyncTask {
82
+ #callback: () => Promise<void>;
83
+ #ctx?: Context = undefined;
84
+
85
+ #scheduled = false;
86
+ #currentTask: Promise<void> | null = null; // Can't be rejected.
87
+ #nextTask = new Trigger();
88
+
89
+ constructor(callback: () => Promise<void>) {
90
+ this.#callback = callback;
91
+ }
92
+
93
+ get scheduled() {
94
+ return this.#scheduled;
95
+ }
96
+
97
+ /**
98
+ * Context of the resource that owns the task.
99
+ * When the context is disposed, the task is cancelled and cannot be scheduled again.
100
+ */
101
+ open(): void {
102
+ this.#ctx = new Context();
103
+ }
104
+
105
+ /**
106
+ * Closes the task and waits for it to finish if it is running.
107
+ */
108
+ async close(): Promise<void> {
109
+ await this.#ctx?.dispose();
110
+ await this.join();
111
+ this.#ctx = undefined;
112
+ }
113
+
114
+ [Symbol.asyncDispose](): Promise<void> {
115
+ return this.close();
116
+ }
117
+
118
+ /**
119
+ * Schedule the task to run asynchronously.
120
+ */
121
+ // TODO(dmaretskyi): Add scheduleAt. Where the earlier time will override the later one.
122
+ schedule(): void {
123
+ if (!this.#ctx || this.#ctx.disposed) {
124
+ throw new Error('AsyncTask not open');
125
+ }
126
+
127
+ if (this.#scheduled) {
128
+ return; // Already scheduled.
129
+ }
130
+
131
+ scheduleTask(this.#ctx, async () => {
132
+ // The previous task might still be running, so we need to wait for it to finish.
133
+ await this.#currentTask; // Can't be rejected.
134
+
135
+ if (!this.#ctx || this.#ctx.disposed) {
136
+ return;
137
+ }
138
+
139
+ // Reset the flag. New tasks can now be scheduled. They would wait for the callback to finish.
140
+ this.#scheduled = false;
141
+ const completionTrigger = this.#nextTask;
142
+ this.#nextTask = new Trigger(); // Re-create the trigger as opposed to resetting it since there might be listeners waiting for it.
143
+
144
+ // Store the promise so that new tasks could wait for this one to finish.
145
+ this.#currentTask = runInContextAsync(this.#ctx, () => this.#callback()).then(() => {
146
+ completionTrigger.wake();
147
+ });
148
+ });
149
+
150
+ this.#scheduled = true;
151
+ }
152
+
153
+ /**
154
+ * Schedule the task to run and wait for it to finish.
155
+ */
156
+ async runBlocking(): Promise<void> {
157
+ if (this.#ctx?.disposed) {
158
+ throw new ContextDisposedError();
159
+ }
160
+
161
+ this.schedule();
162
+ await this.#nextTask.wait();
163
+ }
164
+
165
+ /**
166
+ * Waits for the current task to finish if it is running.
167
+ * Does not schedule a new task.
168
+ */
169
+ async join(): Promise<void> {
170
+ await this.#currentTask;
171
+ }
172
+ }
173
+
80
174
  export const runInContext = (ctx: Context, fn: () => void) => {
81
175
  try {
82
176
  fn();
@@ -6,7 +6,8 @@ import { describe, expect, test } from 'vitest';
6
6
 
7
7
  import { expectToThrow, raise } from '@dxos/debug';
8
8
 
9
- import { waitForCondition } from './testing';
9
+ import { until, waitForCondition } from './testing';
10
+ import { sleep } from './timeout';
10
11
 
11
12
  describe('waitForCondition', () => {
12
13
  test('succeeds', async () => {
@@ -27,3 +28,42 @@ describe('waitForCondition', () => {
27
28
  ).rejects.toThrow('test');
28
29
  });
29
30
  });
31
+
32
+ describe('until', () => {
33
+ test('success', async () => {
34
+ const value = await until<number>(async (resolve) => {
35
+ await sleep(100);
36
+ resolve(100);
37
+ return 1;
38
+ });
39
+
40
+ expect(value).to.equal(100);
41
+ });
42
+
43
+ test('error', async () => {
44
+ await expect(async () => {
45
+ await until(async (resolve, reject) => {
46
+ await sleep(100);
47
+ reject(new Error());
48
+ });
49
+ }).rejects.toThrowError();
50
+ });
51
+
52
+ test('catch', async () => {
53
+ await expect(async () => {
54
+ await until(async () => {
55
+ await sleep(100);
56
+ throw new Error();
57
+ });
58
+ }).rejects.toThrowError();
59
+ });
60
+
61
+ test('timeout', async () => {
62
+ await expect(async () => {
63
+ await until(async (resolve) => {
64
+ await sleep(500);
65
+ resolve();
66
+ }, 100); // Timeout before complete.
67
+ }).rejects.toThrowError();
68
+ });
69
+ });
package/src/testing.ts CHANGED
@@ -54,3 +54,56 @@ export const waitForCondition = <FunctionType extends (...args: any) => any>({
54
54
 
55
55
  return trigger.wait({ timeout });
56
56
  };
57
+
58
+ export type UntilCallback<T> = (resolve: (value: T) => void, reject: (error: Error) => void) => Promise<T> | void;
59
+
60
+ /**
61
+ * Awaits promise.
62
+ */
63
+ export const until = <T = void>(cb: UntilCallback<T>, timeout?: number): Promise<T> => {
64
+ return new Promise((resolve, reject) => {
65
+ const t =
66
+ timeout &&
67
+ setTimeout(() => {
68
+ reject(new Error(`Timeout after ${t}ms`));
69
+ }, timeout);
70
+
71
+ setTimeout(async () => {
72
+ try {
73
+ await cb(
74
+ (value: T) => {
75
+ t && clearTimeout(t);
76
+ resolve(value);
77
+ },
78
+ (error: Error) => {
79
+ t && clearTimeout(t);
80
+ reject(error);
81
+ },
82
+ );
83
+ } catch (err) {
84
+ reject(err);
85
+ }
86
+ });
87
+ });
88
+ };
89
+
90
+ /**
91
+ * Wait until promise resolves.
92
+ */
93
+ export const untilPromise = <T = void>(cb: () => Promise<T>) => cb();
94
+
95
+ /**
96
+ * Wait until error is thrown.
97
+ */
98
+ export const untilError = (cb: () => Promise<any>) => {
99
+ return new Promise((resolve, reject) => {
100
+ setTimeout(async () => {
101
+ try {
102
+ await cb();
103
+ reject(new Error('No error was thrown.'));
104
+ } catch (err) {
105
+ resolve(err);
106
+ }
107
+ });
108
+ });
109
+ };