Skip to content

Commit 3df1087

Browse files
committed
feat: add DisposableHandle and AsyncDisposableHandle
1 parent 7c072ef commit 3df1087

File tree

6 files changed

+290
-0
lines changed

6 files changed

+290
-0
lines changed

README.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,92 @@ disposeAggregator.add(async () => {
187187
disposeAggregator.dispose();
188188
```
189189

190+
### `DisposableHandle`
191+
An object that provides a `.dispose()` method that can called only once.
192+
193+
Calling `.dispose()` will call the provided `onDispose` function only once.
194+
Any subsequent calls to `.dispose()` will do nothing.
195+
196+
```typescript
197+
import {DisposableHandle} from "lifecycle-utils";
198+
199+
function createHandle() {
200+
console.log("allocating resources");
201+
202+
return new DisposableHandle(() => {
203+
console.log("resources disposed");
204+
});
205+
}
206+
207+
const handle = createHandle();
208+
handle.dispose();
209+
```
210+
211+
Using the `using` feature of TypeScript is also supported:
212+
```typescript
213+
import {DisposableHandle} from "lifecycle-utils";
214+
215+
function createHandle() {
216+
console.log("allocating resources");
217+
218+
return new DisposableHandle(() => {
219+
console.log("resources disposed");
220+
});
221+
}
222+
223+
function doWork() {
224+
using handle = createHandle();
225+
}
226+
227+
doWork();
228+
// resources disposed
229+
// the dispose function was called since the scope of the `doWork` function ended
230+
```
231+
232+
### `AsyncDisposableHandle`
233+
An object that provides an async `.dispose()` method that can called only once.
234+
235+
Calling `.dispose()` will call the provided `onDispose` function only once.
236+
Any subsequent calls to `.dispose()` will do nothing.
237+
238+
```typescript
239+
import {AsyncDisposableHandle} from "lifecycle-utils";
240+
241+
function createHandle() {
242+
console.log("allocating resources");
243+
244+
return new AsyncDisposableHandle(async () => {
245+
await new Promise(resolve => setTimeout(resolve, 1000));
246+
console.log("resources disposed");
247+
});
248+
}
249+
250+
const handle = createHandle();
251+
await handle.dispose();
252+
```
253+
254+
Using the `await using` feature of TypeScript is also supported:
255+
```typescript
256+
import {AsyncDisposableHandle} from "lifecycle-utils";
257+
258+
function createHandle() {
259+
console.log("allocating resources");
260+
261+
return new AsyncDisposableHandle(async () => {
262+
await new Promise(resolve => setTimeout(resolve, 1000));
263+
console.log("resources disposed");
264+
});
265+
}
266+
267+
async function doWork() {
268+
await using handle = createHandle();
269+
}
270+
271+
await doWork();
272+
// resources disposed
273+
// the dispose function was called since the scope of the `doWork` function ended
274+
```
275+
190276
### `MultiKeyMap`
191277
`MultiKeyMap` is a utility class that works like a `Map`, but accepts multiple values as the key for each value.
192278

src/AsyncDisposableHandle.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* An object that provides an async `.dispose()` method that can called only once.
3+
*
4+
* Calling `.dispose()` will call the provided `onDispose` function only once.
5+
* Any subsequent calls to `.dispose()` will do nothing.
6+
*/
7+
export class AsyncDisposableHandle {
8+
/** @internal */ private _onDispose: (() => Promise<void>) | undefined;
9+
10+
public constructor(onDispose: () => Promise<void>) {
11+
this._onDispose = onDispose;
12+
13+
this.dispose = this.dispose.bind(this);
14+
this[Symbol.asyncDispose] = this[Symbol.asyncDispose].bind(this);
15+
}
16+
17+
public get disposed() {
18+
return this._onDispose == null;
19+
}
20+
21+
public async [Symbol.asyncDispose]() {
22+
await this.dispose();
23+
}
24+
25+
public async dispose() {
26+
if (this._onDispose != null) {
27+
const onDispose = this._onDispose;
28+
delete this._onDispose;
29+
30+
await onDispose();
31+
}
32+
}
33+
}

src/DisposableHandle.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* An object that provides a `.dispose()` method that can called only once.
3+
*
4+
* Calling `.dispose()` will call the provided `onDispose` function only once.
5+
* Any subsequent calls to `.dispose()` will do nothing.
6+
*/
7+
export class DisposableHandle {
8+
/** @internal */ private _onDispose: (() => void) | undefined;
9+
10+
public constructor(onDispose: () => void) {
11+
this._onDispose = onDispose;
12+
13+
this.dispose = this.dispose.bind(this);
14+
this[Symbol.dispose] = this[Symbol.dispose].bind(this);
15+
}
16+
17+
public get disposed() {
18+
return this._onDispose == null;
19+
}
20+
21+
public [Symbol.dispose]() {
22+
this.dispose();
23+
}
24+
25+
public dispose() {
26+
if (this._onDispose != null) {
27+
const onDispose = this._onDispose;
28+
delete this._onDispose;
29+
30+
onDispose();
31+
}
32+
}
33+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export * from "./LongTimeout.js";
44
export * from "./State.js";
55
export * from "./DisposeAggregator.js";
66
export * from "./AsyncDisposeAggregator.js";
7+
export * from "./DisposableHandle.js";
8+
export * from "./AsyncDisposableHandle.js";
79
export * from "./MultiKeyMap.js";
810
export * from "./splitText.js";
911
export * from "./DisposedError.js";

test/AsyncDisposableHandle.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import {describe, expect, test} from "vitest";
2+
import {AsyncDisposableHandle} from "../src/index.js";
3+
4+
describe("AsyncDisposableHandle", () => {
5+
test("disposing only happens once", async () => {
6+
let disposeTimes = 0;
7+
const handle = new AsyncDisposableHandle(async () => {
8+
disposeTimes++;
9+
});
10+
11+
expect(disposeTimes).toBe(0);
12+
expect(handle.disposed).toBe(false);
13+
14+
await handle.dispose();
15+
expect(disposeTimes).toBe(1);
16+
expect(handle.disposed).toBe(true);
17+
18+
await handle.dispose();
19+
expect(disposeTimes).toBe(1);
20+
expect(handle.disposed).toBe(true);
21+
});
22+
23+
test("marked as disposed before the callback finishes", async () => {
24+
let disposeTimes = 0;
25+
const handle = new AsyncDisposableHandle(async () => {
26+
disposeTimes++;
27+
});
28+
29+
expect(disposeTimes).toBe(0);
30+
expect(handle.disposed).toBe(false);
31+
32+
void handle.dispose();
33+
expect(disposeTimes).toBe(1);
34+
expect(handle.disposed).toBe(true);
35+
36+
void handle.dispose();
37+
expect(disposeTimes).toBe(1);
38+
expect(handle.disposed).toBe(true);
39+
});
40+
41+
test("Symbol.dispose works", async () => {
42+
let disposeTimes = 0;
43+
const handle = new AsyncDisposableHandle(async () => {
44+
disposeTimes++;
45+
});
46+
47+
expect(disposeTimes).toBe(0);
48+
expect(handle.disposed).toBe(false);
49+
50+
await handle[Symbol.asyncDispose]();
51+
expect(disposeTimes).toBe(1);
52+
expect(handle.disposed).toBe(true);
53+
54+
await handle[Symbol.asyncDispose]();
55+
expect(disposeTimes).toBe(1);
56+
expect(handle.disposed).toBe(true);
57+
});
58+
59+
test("Storing dispose function in a variable", () => {
60+
let disposeTimes = 0;
61+
const handle = new AsyncDisposableHandle(async () => {
62+
disposeTimes++;
63+
});
64+
65+
expect(disposeTimes).toBe(0);
66+
expect(handle.disposed).toBe(false);
67+
68+
const dispose = handle.dispose;
69+
dispose();
70+
expect(disposeTimes).toBe(1);
71+
expect(handle.disposed).toBe(true);
72+
73+
dispose();
74+
expect(disposeTimes).toBe(1);
75+
expect(handle.disposed).toBe(true);
76+
});
77+
});

test/DisposableHandle.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {describe, expect, test} from "vitest";
2+
import {DisposableHandle} from "../src/index.js";
3+
4+
describe("DisposableHandle", () => {
5+
test("disposing only happens once", () => {
6+
let disposeTimes = 0;
7+
const handle = new DisposableHandle(() => {
8+
disposeTimes++;
9+
});
10+
11+
expect(disposeTimes).toBe(0);
12+
expect(handle.disposed).toBe(false);
13+
14+
handle.dispose();
15+
expect(disposeTimes).toBe(1);
16+
expect(handle.disposed).toBe(true);
17+
18+
handle.dispose();
19+
expect(disposeTimes).toBe(1);
20+
expect(handle.disposed).toBe(true);
21+
});
22+
23+
test("Symbol.dispose works", () => {
24+
let disposeTimes = 0;
25+
const handle = new DisposableHandle(() => {
26+
disposeTimes++;
27+
});
28+
29+
expect(disposeTimes).toBe(0);
30+
expect(handle.disposed).toBe(false);
31+
32+
handle[Symbol.dispose]();
33+
expect(disposeTimes).toBe(1);
34+
expect(handle.disposed).toBe(true);
35+
36+
handle[Symbol.dispose]();
37+
expect(disposeTimes).toBe(1);
38+
expect(handle.disposed).toBe(true);
39+
});
40+
41+
test("Storing dispose function in a variable", () => {
42+
let disposeTimes = 0;
43+
const handle = new DisposableHandle(() => {
44+
disposeTimes++;
45+
});
46+
47+
expect(disposeTimes).toBe(0);
48+
expect(handle.disposed).toBe(false);
49+
50+
const dispose = handle.dispose;
51+
dispose();
52+
expect(disposeTimes).toBe(1);
53+
expect(handle.disposed).toBe(true);
54+
55+
dispose();
56+
expect(disposeTimes).toBe(1);
57+
expect(handle.disposed).toBe(true);
58+
});
59+
});

0 commit comments

Comments
 (0)