Skip to content

Commit e6eb0ce

Browse files
committed
fix: drop any in abort controller
Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent 834e00c commit e6eb0ce

File tree

2 files changed

+293
-22
lines changed

2 files changed

+293
-22
lines changed
Lines changed: 185 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,100 @@
11
/**
22
* This special AbortController is used to wait for all the abort handlers to finish before resolving the promise.
33
*/
4+
type AbortListener = EventListenerOrEventListenerObject
5+
6+
type ListenerRecord = {
7+
wrapped: EventListener
8+
cleanup: () => void
9+
}
10+
411
export class AsyncAbortController extends AbortController {
5-
protected promises: Promise<any>[] = []
12+
protected runningPromises = new Set<Promise<void>>()
13+
protected abortListeners = new WeakMap<AbortListener, Map<boolean, ListenerRecord>>()
614
protected _nextGroup?: AsyncAbortController
715

816
constructor() {
917
super()
1018

11-
const originalEventListener = this.signal.addEventListener
19+
const originalAddEventListener = this.signal.addEventListener.bind(this.signal)
20+
const originalRemoveEventListener = this.signal.removeEventListener.bind(this.signal)
1221

1322
// Patch event addEventListener to keep track of listeners and their promises
14-
this.signal.addEventListener = (type: string, listener: any, options: any) => {
23+
this.signal.addEventListener = (
24+
type: string,
25+
listener: EventListenerOrEventListenerObject | null,
26+
options?: boolean | AddEventListenerOptions
27+
) => {
28+
if (!listener) {
29+
return
30+
}
31+
1532
if (type !== 'abort') {
16-
return originalEventListener.call(this.signal, type, listener, options)
33+
return originalAddEventListener(type, listener, options)
34+
}
35+
36+
if (this.signal.aborted) {
37+
return originalAddEventListener(type, listener, options)
38+
}
39+
40+
const capture = getCaptureOption(options)
41+
const existingRecord = this.getAbortListenerRecord(listener, capture)
42+
if (existingRecord) {
43+
return originalAddEventListener(type, existingRecord.wrapped, options)
44+
}
45+
46+
const registrationSignal = getRegistrationSignal(options)
47+
if (registrationSignal?.aborted) {
48+
return
49+
}
50+
51+
let wrapped!: EventListener
52+
const cleanupRegistrationSignal = this.watchListenerRemovalSignal(
53+
registrationSignal,
54+
listener,
55+
capture
56+
)
57+
58+
wrapped = (event: Event) => {
59+
this.deleteAbortListenerRecord(listener, capture)
60+
originalRemoveEventListener(type, wrapped, capture)
61+
62+
const runningPromise = this.invokeAbortListener(listener, event)
63+
this.runningPromises.add(runningPromise)
64+
void runningPromise.finally(() => {
65+
this.runningPromises.delete(runningPromise)
66+
})
1767
}
1868

19-
let resolving: undefined | (() => Promise<void>) = undefined
20-
const promise = new Promise<void>((resolve, reject) => {
21-
resolving = async (): Promise<void> => {
22-
return Promise.resolve()
23-
.then(() => listener())
24-
.then(() => {
25-
resolve()
26-
})
27-
.catch((error) => {
28-
reject(error)
29-
})
30-
}
69+
this.setAbortListenerRecord(listener, capture, {
70+
wrapped,
71+
cleanup: cleanupRegistrationSignal,
3172
})
32-
this.promises.push(promise)
3373

34-
if (!resolving) {
35-
throw new Error('resolve is undefined')
74+
return originalAddEventListener(type, wrapped, options)
75+
}
76+
77+
this.signal.removeEventListener = (
78+
type: string,
79+
listener: EventListenerOrEventListenerObject | null,
80+
options?: boolean | EventListenerOptions
81+
) => {
82+
if (!listener) {
83+
return
84+
}
85+
86+
if (type !== 'abort') {
87+
return originalRemoveEventListener(type, listener, options)
88+
}
89+
90+
const capture = getCaptureOption(options)
91+
const record = this.getAbortListenerRecord(listener, capture)
92+
if (!record) {
93+
return originalRemoveEventListener(type, listener, options)
3694
}
3795

38-
return originalEventListener.call(this.signal, type, resolving, options)
96+
this.deleteAbortListenerRecord(listener, capture)
97+
return originalRemoveEventListener(type, record.wrapped, options)
3998
}
4099
}
41100

@@ -50,8 +109,8 @@ export class AsyncAbortController extends AbortController {
50109

51110
async abortAsync() {
52111
this.abort()
53-
while (this.promises.length > 0) {
54-
const promises = this.promises.splice(0, 100)
112+
while (this.runningPromises.size > 0) {
113+
const promises = Array.from(this.runningPromises)
55114
await Promise.allSettled(promises)
56115
}
57116
await this.abortNextGroup()
@@ -62,4 +121,108 @@ export class AsyncAbortController extends AbortController {
62121
await this._nextGroup.abortAsync()
63122
}
64123
}
124+
125+
protected invokeAbortListener(listener: AbortListener, event: Event): Promise<void> {
126+
try {
127+
const result =
128+
typeof listener === 'function'
129+
? listener.call(this.signal, event)
130+
: listener.handleEvent(event)
131+
132+
return Promise.resolve(result).then(() => undefined)
133+
} catch (error) {
134+
return Promise.reject(error)
135+
}
136+
}
137+
138+
protected getAbortListenerRecord(
139+
listener: AbortListener,
140+
capture: boolean
141+
): ListenerRecord | undefined {
142+
return this.abortListeners.get(listener)?.get(capture)
143+
}
144+
145+
protected setAbortListenerRecord(
146+
listener: AbortListener,
147+
capture: boolean,
148+
record: ListenerRecord
149+
) {
150+
const records = this.abortListeners.get(listener) ?? new Map<boolean, ListenerRecord>()
151+
records.set(capture, record)
152+
this.abortListeners.set(listener, records)
153+
}
154+
155+
protected deleteAbortListenerRecord(listener: AbortListener, capture: boolean) {
156+
const records = this.abortListeners.get(listener)
157+
const record = records?.get(capture)
158+
if (!records || !record) {
159+
return
160+
}
161+
162+
record.cleanup()
163+
records.delete(capture)
164+
165+
if (records.size === 0) {
166+
this.abortListeners.delete(listener)
167+
}
168+
}
169+
170+
protected watchListenerRemovalSignal(
171+
signal: AbortSignal | undefined,
172+
listener: AbortListener,
173+
capture: boolean
174+
): () => void {
175+
if (!signal) {
176+
return () => {}
177+
}
178+
179+
const onAbort = () => {
180+
this.deleteAbortListenerRecord(listener, capture)
181+
}
182+
183+
addNativeEventListener(signal, 'abort', onAbort, { once: true })
184+
185+
return () => {
186+
removeNativeEventListener(signal, 'abort', onAbort, { capture: false })
187+
}
188+
}
189+
}
190+
191+
const nativeAddEventListener = EventTarget.prototype.addEventListener
192+
const nativeRemoveEventListener = EventTarget.prototype.removeEventListener
193+
194+
function addNativeEventListener(
195+
target: EventTarget,
196+
type: string,
197+
listener: EventListenerOrEventListenerObject,
198+
options?: boolean | AddEventListenerOptions
199+
) {
200+
nativeAddEventListener.call(target, type, listener, options)
201+
}
202+
203+
function removeNativeEventListener(
204+
target: EventTarget,
205+
type: string,
206+
listener: EventListenerOrEventListenerObject,
207+
options?: boolean | EventListenerOptions
208+
) {
209+
nativeRemoveEventListener.call(target, type, listener, options)
210+
}
211+
212+
function getCaptureOption(options?: boolean | EventListenerOptions): boolean {
213+
if (typeof options === 'boolean') {
214+
return options
215+
}
216+
217+
return options?.capture ?? false
218+
}
219+
220+
function getRegistrationSignal(
221+
options?: boolean | AddEventListenerOptions
222+
): AbortSignal | undefined {
223+
if (typeof options === 'boolean') {
224+
return undefined
225+
}
226+
227+
return options?.signal
65228
}

src/test/async-abort-controller.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,112 @@ describe('AsyncAbortController', () => {
6969

7070
expect(order).toEqual(['root:start', 'root:end', 'child', 'grandchild'])
7171
})
72+
73+
it('forwards the real abort event to function listeners with the signal as context', async () => {
74+
const controller = new AsyncAbortController()
75+
const seen: {
76+
target: EventTarget | null
77+
currentTarget: EventTarget | null
78+
context: unknown
79+
} = {
80+
target: null,
81+
currentTarget: null,
82+
context: undefined,
83+
}
84+
85+
controller.signal.addEventListener('abort', function (event) {
86+
seen.target = event.target
87+
seen.currentTarget = event.currentTarget
88+
seen.context = this
89+
})
90+
91+
await controller.abortAsync()
92+
93+
expect(seen.target).toBe(controller.signal)
94+
expect(seen.currentTarget).toBe(controller.signal)
95+
expect(seen.context).toBe(controller.signal)
96+
})
97+
98+
it('waits for handleEvent listeners before aborting nested groups', async () => {
99+
const controller = new AsyncAbortController()
100+
const childGroup = controller.nextGroup
101+
const order: string[] = []
102+
let releaseRootAbort!: () => void
103+
const rootAbortDone = new Promise<void>((resolve) => {
104+
releaseRootAbort = resolve
105+
})
106+
const listener = {
107+
target: null as EventTarget | null,
108+
async handleEvent(event: Event) {
109+
this.target = event.target
110+
order.push('root:start')
111+
await rootAbortDone
112+
order.push('root:end')
113+
},
114+
}
115+
116+
controller.signal.addEventListener('abort', listener)
117+
childGroup.signal.addEventListener('abort', () => {
118+
order.push('child')
119+
})
120+
121+
const abortPromise = controller.abortAsync()
122+
123+
await Promise.resolve()
124+
expect(order).toEqual(['root:start'])
125+
126+
releaseRootAbort()
127+
await abortPromise
128+
129+
expect(listener.target).toBe(controller.signal)
130+
expect(order).toEqual(['root:start', 'root:end', 'child'])
131+
})
132+
133+
it('ignores null abort listeners', async () => {
134+
const controller = new AsyncAbortController()
135+
const nullListener = null as unknown as EventListenerOrEventListenerObject
136+
137+
expect(() => controller.signal.addEventListener('abort', nullListener)).not.toThrow()
138+
await expect(controller.abortAsync()).resolves.toBeUndefined()
139+
})
140+
141+
it('does not invoke or wait on explicitly removed abort listeners', async () => {
142+
const controller = new AsyncAbortController()
143+
const listener = jest.fn()
144+
145+
controller.signal.addEventListener('abort', listener)
146+
controller.signal.removeEventListener('abort', listener)
147+
148+
await expect(controller.abortAsync()).resolves.toBeUndefined()
149+
expect(listener).not.toHaveBeenCalled()
150+
})
151+
152+
it('does not invoke or wait on abort listeners removed by a registration signal', async () => {
153+
const controller = new AsyncAbortController()
154+
const registration = new AbortController()
155+
const listener = jest.fn()
156+
157+
controller.signal.addEventListener('abort', listener, {
158+
signal: registration.signal,
159+
})
160+
161+
registration.abort()
162+
163+
await expect(controller.abortAsync()).resolves.toBeUndefined()
164+
expect(listener).not.toHaveBeenCalled()
165+
})
166+
167+
it('ignores abort listeners registered with an already aborted signal', async () => {
168+
const controller = new AsyncAbortController()
169+
const registration = new AbortController()
170+
const listener = jest.fn()
171+
172+
registration.abort()
173+
controller.signal.addEventListener('abort', listener, {
174+
signal: registration.signal,
175+
})
176+
177+
await expect(controller.abortAsync()).resolves.toBeUndefined()
178+
expect(listener).not.toHaveBeenCalled()
179+
})
72180
})

0 commit comments

Comments
 (0)