Skip to content

Instantly share code, notes, and snippets.

@lqt0223
Last active April 12, 2024 12:23
Show Gist options
  • Save lqt0223/751d0d268f6855ce12329952dd1da4cf to your computer and use it in GitHub Desktop.
Save lqt0223/751d0d268f6855ce12329952dd1da4cf to your computer and use it in GitHub Desktop.
42. A promise implementation that is A+ conformant
// (Tested on Node.js 18.15.0) A promise implementation that is A+ conformant
// To implement something promise-ish (ignoring all edge-cases from A+ spec), there are some guidelines below:
// - manage the state of promise object, to avoid transition between two settled state
// - use 'then' method to store callbacks for resolving / rejecting the promise.
// - implement 3 kinds of chaining behaviors of a promise
// - when resolving a promise, recursively unwrap until a non-thenable value is found
// - a promise can invoke then (to add callbacks) multiple times. When the promise is settled, all cbs should be invoked sequentially
// - the propagation of promise chain
// warn: this does not check if val.then is a function
// make sure to do the check outside of function
const isThenable = val => val && (typeof val === 'function' || typeof val === 'object') && ('then' in val)
class MyPromise {
state = 'pending' // 'pending' | 'fulfilled' | 'rejected'
value = undefined // the resolved value for promise instance, unwrapped until it is not a thenable and stored here
onFulfilledCbs = []
onRejectedCbs = []
// internal methods to resolve an incoming x value
// there are basically 3 cases for x
// - thenable (but not an instance of MyPromise)
// - MyPromise instance
// - other non-thenable
_resolve = (x) => {
// 2.3.1. If promise and x refer to the same object, reject promise with a TypeError as the reason.
if (x === this) {
return this._reject(new TypeError())
}
// thenable case
if (isThenable(x)) {
// 2.3.3.2. If retrieving the property x.then results in a thrown exception e, reject promise with e as the reason.
try {
// if this is a settled MyPromise, pass it on to resolve
if (x instanceof MyPromise) {
if (x.state === 'fulfilled') {
this._resolve(x.value)
return
} else if (x.state === 'rejected') {
this._reject(x.value)
return
}
}
// warn: use as less val.then getter as possible, which is a derived test from test 2.3.3.1
const then = x.then
// 2.3.3.4. If then is not a function, fulfill promise with x.
if (typeof then !== 'function') {
this._settleValue(x)
return
}
// other thenable case or cases for pending MyPromise
// here, we have many codes for dealing with quirk thenables (which are test cases from 2.3.3.3.1)
// the already-settled check is needed for thenable that [tries to fulfill twice]
let settled = false
// the try-catch is needed for thenable that [fulfills but then throws]
try {
// warn: call with 'this' correctly bound
then.call(x, (_val) => {
if (!settled) {
settled = true
this._resolve(_val)
}
}, (_val) => {
if (!settled) {
settled = true
this._reject(_val)
}
})
// if the exception throws after settled, it should be ignored
// else, re-throw
} catch (e) {
if (!settled) {
throw e
}
}
} catch (e) {
this._reject(e)
}
return
}
// for non-thenable value case
this._settleValue(x)
}
// the reject process does not require a recursive unwrapping
_reject = async (reason) => {
this._settleValue(reason, true)
}
// resolve or reject a value (without further unwrapping)
_settleValue = (valOrReason, doReject = false) => {
// do not resolve or reject when promise is already settled
if (this.state !== 'pending') return
// this is for running codes asynchronously (after the possible quickest 'then' invocation)
// we need to make sure that cbs are filled by 'then' invocation on the promise,
// even in the extreme case that the promise is immediately settled
queueMicrotask(() => {
this.state = doReject ? 'rejected' : 'fulfilled'
this.value = valOrReason
// 'then' might be invoked many times on the same promise, so it's an array
const cbs = doReject ? this.onRejectedCbs : this.onFulfilledCbs
while (cbs.length) {
// for each callback added to this promise
const [ cb, nextPromise ] = cbs.shift()
// 2.2.7.2. If either onFulfilled or onRejected throws an exception e, promise2 must be rejected with e as the reason.
try {
if (cb) {
const nextVal = cb(valOrReason)
// because there is a cb that handles, however the state of the current promise, next promise should try to resolve
nextPromise._resolve(nextVal)
// if there is no cb, propagate through
// 2.2.1. Both onFulfilled and onRejected are optional arguments:
} else {
if (doReject) {
nextPromise._reject(valOrReason)
} else {
nextPromise._resolve(valOrReason)
}
}
} catch (e) {
nextPromise._reject(e)
}
}
})
}
constructor(initFn) {
initFn(this._resolve, this._reject)
}
// synchronously adding resolve / reject callback for this promise
then(onFulfilled, onRejected) {
const promise = new MyPromise(() => {})
// make sure that cb is callable
const rejCb = onRejected?.call && onRejected
this.onRejectedCbs.push([rejCb, promise])
const fulCb = onFulfilled?.call && onFulfilled
this.onFulfilledCbs.push([fulCb, promise])
return promise
}
}
const adapter = {
resolved: (val) => new MyPromise((resolve) => resolve(val)),
rejected: (val) => new MyPromise((_, reject) => reject(val)),
deferred: () => {
let res, rej
const p = new MyPromise((resolve, reject) => {
res = resolve
rej = reject
})
return {
promise: p,
resolve: res,
reject: rej,
}
}
}
var promisesAplusTests = require("promises-aplus-tests")
promisesAplusTests(adapter, { grep: '' })
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment