Last active
October 23, 2024 14:49
-
-
Save andrewcourtice/ef1b8f14935b409cfe94901558ba5594 to your computer and use it in GitHub Desktop.
Async cancellation using promise extension and abort controller
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
This a basic implementation of task cancellation using a Promise extension | |
combined with an AbortController. There are 3 major benefits to this implementation: | |
1. Because it's just an extension of a Promise the Task is fully | |
compatible with the async/await syntax. | |
2. By using the abort controller as a native cancellation token | |
fetch requests and certain DOM operations can be cancelled inside the task. | |
3. By passing the controller from parent tasks to new child tasks an entire | |
async chain can be cancelled using a single AbortController. | |
*/ | |
type Product<T = any> = (...args: any[]) => T; | |
type TaskAbortCallback = (reason?: any) => void; | |
type TaskExecutor<T> = ( | |
resolve: (value: T | PromiseLike<T>) => void, | |
reject: (reason?: any) => any, | |
controller: AbortController, | |
onAbort: (callback: TaskAbortCallback) => void | |
) => void; | |
function safeRun<T = any>(bodyInvokee: Product<T>, finallyInvokee: Product<void>): Product<T> { | |
return (...args: any[]) => { | |
try { | |
return bodyInvokee(...args); | |
} finally { | |
finallyInvokee(); | |
} | |
}; | |
} | |
class Task<T = void> extends Promise<T> { | |
private controller: AbortController; | |
private abortReason: any; | |
constructor(executor: TaskExecutor<T>, controller: AbortController = new AbortController()) { | |
if (controller.signal.aborted) { | |
throw new Error('Cannot attach task to an already aborted controller'); | |
} | |
const listeners = new Set<Product<void>>(); | |
const addListener = (listener: Product<void>) => { | |
listeners.add(listener); | |
controller.signal.addEventListener('abort', listener); | |
}; | |
const removeListener = (listener: Product<void>) => { | |
listeners.delete(listener); | |
controller.signal.removeEventListener('abort', listener); | |
}; | |
const cleanup = () => { | |
if (listeners.size > 0) { | |
listeners.forEach(removeListener); | |
} | |
}; | |
super((_resolve, _reject) => { | |
const resolve = safeRun(_resolve, cleanup); | |
const reject = safeRun(_reject, cleanup); | |
const onAbort = (callback: TaskAbortCallback) => { | |
const listener = safeRun( | |
() => callback(this.abortReason), | |
() => removeListener(listener) | |
); | |
addListener(listener); | |
}; | |
executor(resolve, reject, controller, onAbort); | |
}); | |
this.controller = controller; | |
} | |
public get signal(): AbortSignal { | |
return this.controller.signal; | |
} | |
public get hasAborted(): boolean { | |
return this.signal.aborted; | |
} | |
public abort(reason?: any): this { | |
this.abortReason = reason; | |
this.controller.abort(); | |
return this; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function createTask(message: string, timeout: number = 1000): Task<string> { | |
// Create a new task the same way you create a promise | |
return new Task((resolve, reject, controller, onAbort) => { | |
const handle = window.setTimeout(() => resolve(message), 1000); | |
/* | |
Register an onAbort handler to instruct the task how to handle cancellation. | |
This is also handy for cleaning up resources such as timer handles | |
*/ | |
onAbort(() => { | |
window.clearTimeout(handle); | |
reject(); | |
}); | |
/* | |
The controller could be passed to any number of child tasks | |
to synchronise the cancellation all the way through a chain | |
of async operations. | |
eg. new Task((resolve, reject, controller, onAbort) => { | |
// Do some nested async operation | |
}, controller); | |
Note the second parameter to the Task constructor is an | |
existing AbortController. Handy for passing controllers | |
down to child tasks. | |
*/ | |
}); | |
} | |
async function run(): Promise<void> { | |
const task = createTask('hello'); | |
// Abort the task before it has a chance to complete | |
window.setTimeout(() => task.abort(), 500); | |
try { | |
const result = await task; | |
console.log(result); | |
} catch { | |
console.log('aborted'); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function getUserData(id: string): Task<object> { | |
// Create a new task the same way you create a promise | |
return new Task(async (resolve, reject, controller) => { | |
try { | |
/* | |
Pass the abort controller signal into the fetch api | |
to cancel the request when the task is aborted | |
*/ | |
const response = await window.fetch(`/api/users/${id}`, { | |
signal: controller.signal | |
}); | |
const data = await response.json(); | |
resolve(data); | |
} catch (error) { | |
reject(error) | |
} | |
}); | |
} | |
async function run(): Promise<void> { | |
const task = getUserData('some-id'); | |
// Abort the task before it has a chance to complete | |
window.setTimeout(() => task.abort(), 100); | |
try { | |
const result = await task; | |
console.log(result); | |
} catch { | |
console.log('aborted'); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment