Skip to content

PBjs Core: do not rely on an extendable window.Promise #9558

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Mar 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 59 additions & 40 deletions src/utils/promise.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import {getGlobal} from '../prebidGlobal.js';

const SUCCESS = 0;
const FAIL = 1;

/**
* A version of Promise that runs callbacks synchronously when it can (i.e. after it's been fulfilled or rejected).
*/
export class GreedyPromise extends (getGlobal().Promise || Promise) {
export class GreedyPromise {
#result;
#callbacks;
#parent = null;

/**
* Convenience wrapper for setTimeout; takes care of returning an already fulfilled GreedyPromise when the delay is zero.
Expand All @@ -24,53 +21,33 @@ export class GreedyPromise extends (getGlobal().Promise || Promise) {
}

constructor(resolver) {
if (typeof resolver !== 'function') {
throw new Error('resolver not a function');
}
const result = [];
const callbacks = [];
function handler(type, resolveFn) {
let [resolve, reject] = [SUCCESS, FAIL].map((type) => {
return function (value) {
if (!result.length) {
if (type === SUCCESS && typeof value?.then === 'function') {
value.then(resolve, reject);
} else if (!result.length) {
result.push(type, value);
while (callbacks.length) callbacks.shift()();
resolveFn(value);
}
}
});
try {
resolver(resolve, reject);
} catch (e) {
reject(e);
}
super(
typeof resolver !== 'function'
? resolver // let super throw an error
: (resolve, reject) => {
const rejectHandler = handler(FAIL, reject);
const resolveHandler = (() => {
const done = handler(SUCCESS, resolve);
return value =>
typeof value?.then === 'function' ? value.then(done, rejectHandler) : done(value);
})();
try {
resolver(resolveHandler, rejectHandler);
} catch (e) {
rejectHandler(e);
}
}
);
this.#result = result;
this.#callbacks = callbacks;
}

then(onSuccess, onError) {
if (typeof onError === 'function') {
// if an error handler is provided, attach a dummy error handler to super,
// and do the same for all promises without an error handler that precede this one in a chain.
// This is to avoid unhandled rejection events / warnings for errors that were, in fact, handled;
// since we are not using super's callback mechanisms we need to make it aware of this separately.
let node = this;
while (node) {
super.then.call(node, null, () => null);
const next = node.#parent;
node.#parent = null; // since we attached a handler already, we are no longer interested in what will happen later in the chain
node = next;
}
}
const result = this.#result;
const res = new GreedyPromise((resolve, reject) => {
return new this.constructor((resolve, reject) => {
const continuation = () => {
let value = result[1];
let [handler, resolveFn] = result[0] === SUCCESS ? [onSuccess, resolve] : [onError, reject];
Expand All @@ -87,8 +64,50 @@ export class GreedyPromise extends (getGlobal().Promise || Promise) {
}
result.length ? continuation() : this.#callbacks.push(continuation);
});
res.#parent = this;
return res;
}

catch(onError) {
return this.then(null, onError);
}

finally(onFinally) {
let val;
return this.then(
(v) => { val = v; return onFinally(); },
(e) => { val = this.constructor.reject(e); return onFinally() }
).then(() => val);
}

static #collect(promises, collector, done) {
let cnt = promises.length;
function clt() {
collector.apply(this, arguments);
if (--cnt <= 0 && done) done();
}
promises.length === 0 && done ? done() : promises.forEach((p, i) => this.resolve(p).then(
(val) => clt(true, val, i),
(err) => clt(false, err, i)
));
}

static race(promises) {
return new this((resolve, reject) => {
this.#collect(promises, (success, result) => success ? resolve(result) : reject(result));
})
}

static all(promises) {
return new this((resolve, reject) => {
let res = [];
this.#collect(promises, (success, val, i) => success ? res[i] = val : reject(val), () => resolve(res));
})
}

static allSettled(promises) {
return new this((resolve) => {
let res = [];
this.#collect(promises, (success, val, i) => res[i] = success ? {status: 'fulfilled', value: val} : {status: 'rejected', reason: val}, () => resolve(res))
})
}

static resolve(value) {
Expand Down
90 changes: 2 additions & 88 deletions test/spec/unit/utils/promise_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,94 +19,6 @@ describe('GreedyPromise', () => {
})
});

describe('unhandled rejections', () => {
let unhandled, done, stop;

function reset(expectUnhandled) {
let pending = expectUnhandled;
let resolver;
unhandled.reset();
unhandled.callsFake(() => {
pending--;
if (pending === 0) {
resolver();
}
})
done = new Promise((resolve) => {
resolver = resolve;
stop = function () {
if (expectUnhandled === 0) {
resolve()
} else {
resolver = resolve;
}
}
})
}

before(() => {
unhandled = sinon.stub();
window.addEventListener('unhandledrejection', unhandled);
});

after(() => {
window.removeEventListener('unhandledrejection', unhandled);
});

function getUnhandledErrors() {
return unhandled.args.map((args) => args[0].reason);
}

Object.entries({
'simple reject': [1, (P) => { P.reject('err'); stop() }],
'caught reject': [0, (P) => P.reject('err').catch((e) => { stop(); return e })],
'unhandled reject with finally': [1, (P) => P.reject('err').finally(() => 'finally')],
'error handler that throws': [1, (P) => P.reject('err').catch((e) => { stop(); throw e })],
'rejection handled later in the chain': [0, (P) => P.reject('err').then((v) => v).catch((e) => { stop(); return e })],
'multiple errors in one chain': [1, (P) => P.reject('err').then((v) => v).catch((e) => e).then((v) => { stop(); return P.reject(v) })],
'multiple errors in one chain, all handled': [0, (P) => P.reject('err').then((v) => v).catch((e) => e).then((v) => P.reject(v)).catch((e) => { stop(); return e })],
'separate chains for rejection and handling': [1, (P) => {
const p = P.reject('err');
p.catch((e) => { stop(); return e; })
p.then((v) => v);
}],
'separate rejections merged without handling': [2, (P) => {
const p1 = P.reject('err1');
const p2 = P.reject('err2');
p1.then(() => p2).finally(stop);
}],
'separate rejections merged for handling': [0, (P) => {
const p1 = P.reject('err1');
const p2 = P.reject('err2');
P.all([p1, p2]).catch((e) => { stop(); return e });
}],
// eslint-disable-next-line no-throw-literal
'exception in resolver': [1, (P) => new P(() => { stop(); throw 'err'; })],
// eslint-disable-next-line no-throw-literal
'exception in resolver, caught': [0, (P) => new P(() => { throw 'err' }).catch((e) => { stop(); return e })],
'errors from nested promises': [1, (P) => new P((resolve) => setTimeout(() => { resolve(P.reject('err')); stop(); }))],
'errors from nested promises, caught': [0, (P) => new P((resolve) => setTimeout(() => resolve(P.reject('err')))).catch((e) => { stop(); return e })],
}).forEach(([t, [expectUnhandled, op]]) => {
describe(`on ${t}`, () => {
it('should match vanilla Promises', () => {
let vanillaUnhandled;
reset(expectUnhandled);
op(Promise);
return done.then(() => {
vanillaUnhandled = getUnhandledErrors();
reset(expectUnhandled);
op(GreedyPromise);
return done;
}).then(() => {
const actualUnhandled = getUnhandledErrors();
expect(actualUnhandled.length).to.eql(expectUnhandled);
expect(actualUnhandled).to.eql(vanillaUnhandled);
})
})
})
});
});

describe('idioms', () => {
let makePromise, pendingFailure, pendingSuccess;

Expand Down Expand Up @@ -172,9 +84,11 @@ describe('GreedyPromise', () => {
'chained Promise.reject': (P) => P.reject(pendingSuccess),
'chained Promise.reject on failure': (P) => P.reject(pendingFailure),
'simple Promise.all': (P) => P.all([makePromise(P, 'one'), makePromise(P, 'two')]),
'empty Promise.all': (P) => P.all([]),
'Promise.all with scalars': (P) => P.all([makePromise(P, 'one'), 'two']),
'Promise.all with errors': (P) => P.all([makePromise(P, 'one'), makePromise(P, 'two'), makePromise(P, 'err', true)]),
'Promise.allSettled': (P) => P.allSettled([makePromise(P, 'one', true), makePromise(P, 'two'), makePromise(P, 'three', true)]),
'empty Promise.allSettled': (P) => P.allSettled([]),
'Promise.allSettled with scalars': (P) => P.allSettled([makePromise(P, 'value'), 'scalar']),
'Promise.race that succeeds': (P) => P.race([makePromise(P, 'error', true, 10), makePromise(P, 'success')]),
'Promise.race that fails': (P) => P.race([makePromise(P, 'success', false, 10), makePromise(P, 'error', true)]),
Expand Down