diff --git a/.eslintrc b/.eslintrc index 5b98466..fd1999c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -7,7 +7,9 @@ }, "globals": { "AbortController": true, - "AbortSignal": true + "AbortSignal": true, + "WeakRef": true, + "FinalizationRegistry": true }, "parserOptions": { "ecmaVersion": 2018, diff --git a/src/abortcontroller.js b/src/abortcontroller.js index 9a4a849..161e6a0 100644 --- a/src/abortcontroller.js +++ b/src/abortcontroller.js @@ -45,7 +45,154 @@ class Emitter { } } -export class AbortSignal extends Emitter { +const supportsEventTarget = typeof EventTarget !== 'undefined'; +const EventTargetClass = supportsEventTarget ? EventTarget : Emitter; + +const createWeakRef = (value) => { + if (typeof WeakRef !== 'undefined') { + return new WeakRef(value); + } + return { deref: () => value }; +}; + +let symbolId = 0; +const symbolStamp = Math.random() * 99999; +const createSymbol = (name) => { + return typeof Symbol !== 'undefined' ? Symbol(name) : `__Symbol_${symbolStamp}_${symbolId++}__`; +}; + +const createWeakMap = () => { + if (typeof WeakMap !== 'undefined') { + return new WeakMap(); + } + const symbol = createSymbol('WeakMap'); + + return { + get: (key) => key[symbol], + set: (key, value) => { + Object.defineProperty(key, symbol, { + enumerable: false, + configurable: true, + value, + writable: true, + }); + }, + delete: (key) => { delete key[symbol]; }, + }; +}; +const createFinalizationRegistry = (cleanupCallback) => { + if (typeof FinalizationRegistry !== 'undefined') { + return new FinalizationRegistry(cleanupCallback); + } + return { register: () => { }, unregister: () => { } }; +}; + +class IterableWeakSet { + constructor() { + this._head = null; + this._tail = null; + this._finalizationRegistry = createFinalizationRegistry((node) => this._deleteNode(node)); + this._nodes = createWeakMap(); + } + + clear() { + while (this._head) { + this._deleteNode(this._head); + } + } + add(value) { + const currentNode = this._nodes.get(value); + if (currentNode) { + if (currentNode.value.deref()) { + return; + } + this._deleteNode(currentNode); + } + + const node = { value: createWeakRef(value), next: null, prev: null }; + if (!this._head) { + this._head = node; + this._tail = node; + } else { + this._tail.next = node; + node.prev = this._tail; + this._tail = node; + } + this._nodes.set(value, node); + this._finalizationRegistry.register(value, node); + } + + delete(value) { + const node = this._nodes.get(value); + if (!node) return; + this._deleteNode(node); + } + + _deleteNode(node) { + if (!node) return; + this._nodes.delete(node); + const value = node.value.deref(); + if (value) { + this._finalizationRegistry.unregister(value); + } + if (node === this._head) { + if (node.next) { + node.next.prev = null; + } else { + this._tail = node.next; + } + + this._head = node.next; + } else if (node === this._tail) { + if (node.prev) { + node.prev.next = null; + } else { + this._head = node.prev; + } + this._tail = node.prev; + } else { + const prev = node.prev; + const next = node.next; + prev.next = next; + next.prev = prev; + } + } + toArray() { + const result = []; + let node = this._head; + while (node) { + const value = node.value.deref(); + if (value) { + result.push(value); + } else { + this._deleteNode(node); + } + node = node.next; + } + return result; + } +} + + +const controllerSymbol = createSymbol('AbortController'); +const anySignalsSymbol = createSymbol('AbortSignal.any'); + + +/** + * @this AbortSignal + */ +function abortAnySignals() { + const anySignals = this[anySignalsSymbol].toArray(); + + for (const signal of anySignals) { + const controller = signal[controllerSymbol]; + controller.abort(this.reason); + } + this[anySignalsSymbol].clear(); + this.removeEventListener('abort', abortAnySignals); +} + +export class AbortSignal extends EventTargetClass { constructor() { super(); // Some versions of babel does not transpile super() correctly for IE <= 10, if the parent @@ -54,8 +201,8 @@ export class AbortSignal extends Emitter { // https://github.com/babel/babel/issues/3041 // This hack was added as a fix for the issue described here: // https://github.com/Financial-Times/polyfill-library/pull/59#issuecomment-477558042 - if (!this.listeners) { - Emitter.call(this); + if (!this.listeners && !supportsEventTarget) { + EventTargetClass.call(this); } // Compared to assignment, Object.defineProperty makes properties non-enumerable by default and @@ -63,6 +210,8 @@ export class AbortSignal extends Emitter { Object.defineProperty(this, 'aborted', { value: false, writable: true, configurable: true }); Object.defineProperty(this, 'onabort', { value: null, writable: true, configurable: true }); Object.defineProperty(this, 'reason', { value: undefined, writable: true, configurable: true }); + + Object.defineProperty(this, anySignalsSymbol, { value: new IterableWeakSet(), writable: false, configurable: false, enumerable: false }); } toString() { return '[object AbortSignal]'; @@ -73,6 +222,8 @@ export class AbortSignal extends Emitter { if (typeof this.onabort === 'function') { this.onabort.call(this, event); } + + abortAnySignals.call(this); } super.dispatchEvent(event); @@ -114,21 +265,13 @@ export class AbortSignal extends Emitter { */ static any(iterable) { const controller = new AbortController(); - /** - * @this AbortSignal - */ - function abort() { - controller.abort(this.reason); - clean(); - } - function clean() { - for (const signal of iterable) signal.removeEventListener('abort', abort); - } for (const signal of iterable) if (signal.aborted) { controller.abort(signal.reason); break; - } else signal.addEventListener('abort', abort); + } else { + signal[anySignalsSymbol].add(controller.signal); + } return controller.signal; } @@ -139,6 +282,8 @@ export class AbortController { // Compared to assignment, Object.defineProperty makes properties non-enumerable by default and // we want Object.keys(new AbortController()) to be [] for compat with the native impl Object.defineProperty(this, 'signal', { value: new AbortSignal(), writable: true, configurable: true }); + + Object.defineProperty(this.signal, controllerSymbol, { value: this, writable: false, configurable: false, enumerable: false }); } abort(reason) { const signalReason = normalizeAbortReason(reason); @@ -158,5 +303,7 @@ if (typeof Symbol !== 'undefined' && Symbol.toStringTag) { // These are necessary to make sure that we get correct output for: // Object.prototype.toString.call(new AbortController()) AbortController.prototype[Symbol.toStringTag] = 'AbortController'; - AbortSignal.prototype[Symbol.toStringTag] = 'AbortSignal'; + Object.defineProperty(AbortSignal.prototype, Symbol.toStringTag, { + value: 'AbortSignal', + }); }