2

Am looking to use a promise to handle a modal window, such that when the modal window is called via the await syntax, the synchronous execution of the calling function is suspended until the user responds to the modal. The code snippet below extracts the essential elements of the problem. Although it functions, am not sure whether this is a promise antipattern, or whether I'm introducing hidden complexities should errors be thrown in the onclick handlers. The closest Q&A I could find ( Resolve promise at a later time ) doesn't quite answer my issue, as the answers don't appear to apply to a promise held in reserve waiting on a user event to occur...

My stripped down Modal class and sample execution includes the following key elements...

  • class Modal constructs the modal DOM elements, and appends them to the HTML document.
  • class Modal has a method called show which shows the modal (in this simplified example, three buttons) and sets up a promise. The resolve and reject functions of the promise are then held as Modal instance attributes, specifically resolveFunction and rejectFunction.
  • Only when the user hits Okay, Cancel, or CancelThrow is the promise resolved or rejected.
  • function openModal is the function that sets up and shows the modal, and then suspends waiting on the resolution of the promise created by the modal show() method.

<html><head>

<style>

#ModalArea {
  display: none;
}

#ModalArea.show {
  display: block;
}

</style>

<script>

class Modal {
  constructor() {

    this.parentNode = document.getElementById( 'ModalArea' );

    let okay = document.createElement( 'BUTTON' );
    okay.innerText = 'Okay';
    okay.onclick = ( event ) => {
      this.resolveFunction( 'Okay button clicked!' )
    };
    this.parentNode.appendChild( okay );
  
    let cancel = document.createElement( 'BUTTON' );
    cancel.innerText = 'Cancel';
    cancel.onclick = ( event ) => {
      this.rejectFunction( 'Cancel button clicked!' )
    };
    this.parentNode.appendChild( cancel );
    
    let cancelThrow = document.createElement( 'BUTTON' );
    cancelThrow.innerText = 'Cancel w/Throw';
    cancelThrow.onclick = ( event ) => {
      try {
        throw 'Thrown error!';
      } catch( err ) {
        this.rejectFunction( err );
      }
      this.rejectFunction( 'CancelThrow button clicked!' );
    };
    this.parentNode.appendChild( cancelThrow );
    
  }
  
  async show() {
    this.parentNode.classList.add( 'show' );
    
    // Critical code:
    //
    // Is this appropriate to stash away the resolve and reject functions
    // as attributes to a class object, to be used later?!
    //
    return new Promise( (resolve, reject) => {
      this.resolveFunction = resolve;
      this.rejectFunction = reject;
    });
  }

}

async function openModal() {

  // Create new modal buttons...
  let modal = new Modal();
  
  // Show the buttons, but wait for the promise to resolve...
  try {
    document.getElementById( 'Result' ).innerText += await modal.show();
  } catch( err ) {
    document.getElementById( 'Result' ).innerText += err;
  }
  
  // Now that the promise resolved, append more text to the result.
  document.getElementById( 'Result' ).innerText += ' Done!';
  
}

</script>

</head><body>


<button onclick='openModal()'>Open Modal</button>
<div id='ModalArea'></div>
<div id='Result'>Result: </div>
</body></html>

Are there pitfalls to the way I'm handling the resolve and reject functions, and if so, is there a better design pattern to handle this use case?

EDIT

Based on Roamer-1888's guidance, I've arrived at the following cleaner implementation of the deferred promise... (Note that the test of Cancel w/Throw results in the console showing an Uncaught (in Promise) error, but processing continues as defined...)

<html><head>

<style>

#ModalArea {
  display: none;
}

#ModalArea.show {
  display: block;
}

</style>

<script>

class Modal {
  constructor() {

    this.parentNode = document.getElementById( 'ModalArea' );

    this.okay = document.createElement( 'BUTTON' );
    this.okay.innerText = 'Okay';
    this.parentNode.appendChild( this.okay );
  
    this.cancel = document.createElement( 'BUTTON' );
    this.cancel.innerText = 'Cancel';
    this.parentNode.appendChild( this.cancel );
    
    this.cancelThrow = document.createElement( 'BUTTON' );
    this.cancelThrow.innerText = 'Cancel w/Throw';
    this.parentNode.appendChild( this.cancelThrow );
    
  }
  
  async show() {
    this.parentNode.classList.add( 'show' );
    
    let modalPromise = new Promise( (resolve, reject) => {
      this.okay.onclick = (event) => {
        resolve( 'Okay' );
      };
      this.cancel.onclick = ( event ) => {
        reject( 'Cancel' );
      };
      this.cancelThrow.onclick = ( event ) => {
        try {
          throw new Error( 'Test of throwing an error!' );
        } catch ( err ) {
          console.log( 'Event caught error' );
          reject( err );
        }
      };
    });
    
    modalPromise.catch( e => {
      console.log( 'Promise catch fired!' );
    } );
    
    // Clear out the 'modal' buttons after the promise completes.
    modalPromise.finally( () => {
      this.parentNode.innerHTML = '';
    });

    return modalPromise;
  }

}

async function openModal() {

  // Create new modal buttons...
  let modal = new Modal();
  document.getElementById( 'Result' ).innerHTML =  'Result: ';
  
  // Show the buttons, but wait for the promise to resolve...
  try {
    document.getElementById( 'Result' ).innerText += await modal.show();
  } catch( err ) {
    document.getElementById( 'Result' ).innerText += err;
  }
  
  // Now that the promise resolved, append more text to the result.
  document.getElementById( 'Result' ).innerText += ' Done!';  
}

</script>

</head><body>


<button onclick='openModal()'>Open Modal</button>
<div id='ModalArea'></div>
<div id='Result'></div>
</body></html>

Something still seems off though. Having added a promise catch, when selecting Cancel w/Throw, the error propagates through modalPromise.catch, but the console still logs the following error:

Uncaught (in promise) Error: Test of throwing an error! at HTMLButtonElement.cancelThrow.onclick

Trentium
  • 3,419
  • 2
  • 12
  • 19
  • What about the code that you have does not work as expected? – Lewis Feb 15 '20 at 01:07
  • @Christian the code works, but based on the Q&A I reference above, I believe I'm using (and possibly being forced into) a design pattern that does not benefit from: "When an exception happens in the callback you pass to new Promise, the specification for promises is such that the exception will automatically be converted into a promise rejection. So if anything does throw Error... inside the callback you get automatic conversion." Maybe I'm making much ado about nothing, but my sense is that I might be misusing promises... – Trentium Feb 15 '20 at 01:22
  • 1
    The quote "When an exception happens ..." is correct in as far as it goes. What it omits to say is that an error thrown *asynchronously* inside a Promise constructor, will NOT be automatically converted into a promise rejection. In such cases, you must call `reject()` explicitly. – Roamer-1888 Feb 15 '20 at 04:30
  • @Roamer-1888 thanks for the clarification... Also, I think the related question https://stackoverflow.com/questions/26150232 which I overlooked earlier discusses the concept of `Deferred` promises and that's essentially what I'm implementing. It appears to be an accepted design pattern, with a "developer beware" warning concerning thrown errors inside the promise constructor, as you've clarified. – Trentium Feb 15 '20 at 12:51
  • 1
    Personally, I wouldn't write it that way. It would be cleaner for the constructor to assign `this.okay`, `this.cancel` and `this.cancelThrow`, and for `show()` to attach event handlers inside its Promise constructor. I would also (1) chain `.finally(() => { this.parentNode.remove('show'); })`; (2) throw/reject `new Error('reason')`, thus forced errors and natural errors will be of the same type. – Roamer-1888 Feb 15 '20 at 13:38
  • 1
    @Roamer-1888 that's exactly the guidance I am seeking, as it cleanly implements the promise rather than the kludge I cooked up! Wasn't even aware of the promise function `finally`(!), which is mentioned in passing only once in the two references I've alluded... I will append to my question an edit based on your comment, although am not sure I've faithfully interpreted your throw/reject comment... – Trentium Feb 15 '20 at 14:31

2 Answers2

1

The reason you see unexpected (unintuitive) behavior lies in this part of your code:

async show() {
    // ...

    modalPromise.catch(e => {
      console.log( 'Promise CATCH fired!' );
    });

    modalPromise.finally(() => {
      console.log( 'Promise FINALLY fired!' );
      this.parentNode.innerHTML = '';
    });

    return modalPromise;
}

If you change above into following, the error handling behavior would be normal:

async show() {
    // ...

    return modalPromise.catch(e => {
      console.log( 'Promise CATCH fired!' );
    }).finally(() => {
      console.log( 'Promise FINALLY fired!' );
      this.parentNode.innerHTML = '';
    });
}

Each promise API call, .then, .catch or .finally, will produce a new descending promise. So it actually forms a tree structure, with each API call it produces a new branch.

The requirement is that, each branch should have an error handler attached, otherwise uncaught error is thrown.

(: Because of the propagating nature of error inside chained promises, you don't have to attach error handler to every node on the branch, just apply it somewhere downstream relative to the source of error.)

Back to your case. The way you write it, actually branches out into two descendants, while child_two branch does not have an error handler, thus uncaught error is thrown.

const ancestor = new Promise(...)
const child_one = ancestor.catch(fn)
const child_two = ancestor.finally(fn)
return ancestor

Rule of thumb when handling error in promise? Don't branch, chain them, keep it linear.

Admittedly this is quite a confusing behavior. I put together the following snippet to show case. You'll need to open the browser console to see uncaught error.

function defer() {
  let resolve
  let reject
  const promise = new Promise((rsv, rjt) => {
    resolve = rsv
    reject = rjt
  })

  return {
    promise,
    resolve,
    reject,
  }
}

// 1 uncaught
function case_a() {
  const d = defer()
  d.promise.catch(e => console.log('[catch(a1)]', e))
  d.promise.finally(e => console.log('[finally(a1)]', e)) // uncaught
  d.reject('apple')
}

// all caught!
function case_b() {
  const d = defer()
  d.promise.catch(e => console.log('[catch(b1)]', e))
  d.promise.finally(e => console.log('[finally(b1)]', e)).catch(e => console.log('[catch(b2)]', e))
  d.reject('banana')
}

// 2 uncaught
function case_c() {
  const d = defer()
  d.promise.catch(e => console.log('[catch(c1)]', e))
  d.promise.finally(e => console.log('[finally(c1)]', e)).catch(e => console.log('[catch(c2)]', e))
  d.promise.finally(e => console.log('[finally(c2)]', e)) // uncaught
  d.promise.finally(e => console.log('[finally(c3)]', e)) // uncaught
  d.reject('cherry')
}

function test() {
  case_a()
  case_b()
  case_c()
}

test()

Follow-up update in response to OP's edit.

I wanna explain briefly about execution order of promise, but then I realize it's worth a long article to explain it clearly So I'll just highlight the following part:

  1. Every line of code in the body of case_a through case_c, upon called, is executed in sync jobs.
    1. That includes calling .then,.catch and .finally, which means attachment of handler callbacks to promise is sync operation.
    2. Also d.resolve and d.reject, which means settlement of a promise COULD happen synchronously, and in this example it does indeed.
  2. Async jobs in JS can only be expressed in the form of callback functions. And in the case of promise:
    1. All handler callbacks are async jobs.
    2. The only promise-related callback that is a sync job, is the executor callback new Promise(executorCallback).
  3. And lastly the obvious, async jobs always wait for sync jobs to complete. Async jobs happens in a separate round of execution after sync jobs, they don't interweave.

With above rules in mind, let's review a new example.

function defer() {
  const d = {};
  const executorCallback = (resolve, reject) => {
    Object.assign(d, { resolve, reject });
  };
  d.promise = new Promise(executorCallback);
  return d;
}

function new_case() {
  // 1. inside `defer()` the `executorCallback` is called sync'ly
  const d = defer();

  // 2. `f1` handler callback is attached to `branch_1` sync'ly
  const f1 = () => console.log("finally branch_1");
  const branch_1 = d.promise.finally(f1);

  // 3. `c1` handler callback is attached to `branch_1` sync'ly
  const c1 = (e) => console.log("catch branch_1", e);
  branch_1.catch(c1);

  // 4. ancestor promise `d.promise` is settled to `rejected` state,
  d.reject("I_dont_wanna_get_caught");

  // CODE BELOW IS EQUIVALENT TO YOUR PASSING-AROUND PROMISE_B
  // what really matters is just execution order, not location of code

  // 5. `f2` handler callback is attached to `branch_2` sync'ly
  const f2 = () => console.log("finally branch_2");
  const branch_2 = d.promise.finally(f2);

  // 6. `c2` handler callback is attached to `branch_2` sync'ly
  const c2 = (e) => console.log("catch branch_2", e);
  branch_2.catch(c2);
}

new_case()

Rule 1. all code in the body of a sync function is called synchronously, so the bullet-pointed line of code are all executed in their numerical order.

I'd like to highlight point 4 and 6

// 4.
d.reject("I_dont_wanna_get_caught");

// 6.
branch_2.catch(c2);

First, point 4 can be viewed in a way as continuation of executorCallback. If you think about it, d.reject is just a variable hoisted outside of executorCallback, and now we just pull the trigger from outside. Remember rule 2.2, executorCallback is a sync job.

Second, even after we've already rejected d.proimise at point 4, we're still able to attach c2 handler callback at point 6 and successfully catch the error thanks to rule 2.1.

all handler callbacks are async jobs

Thus we don't instantly get an the uncaught error after point 4, the rejection happens sync'ly, but the rejected error is thrown async'ly.

And because sync code is prioritized before async code, we have plenty of time to attach c2 handler to catch the error.

hackape
  • 18,643
  • 2
  • 29
  • 57
  • 1
    Ah, the light bulb just turned on! Thanks for the crystal clear explanation! I now have a handle on the issue, and have tweaked your code snippet (as an edit to my answer) to exemplify a squirrely way of passing around the promise and its finally() branch, all the while avoiding the Uncaught (in promise) errors. – Trentium Apr 18 '21 at 16:05
0

FWIW... After studying error handling of Promises ( https://javascript.info/async-await#error-handling and https://javascript.info/promise-error-handling ) and much experimentation, I've concluded (maybe erroneously) that a deferred Promise reject will result in an Uncaught (in promise) error.

  • That is, a javascript error, despite being caught and handled within the execution of a deferred Promise, concluded with a Promise reject, will appear as an Uncaught (in promise) error in the console (even with the error being wrapped in a try..catch in both the deferred Promise and the calling function that created the Promise!).
  • If, though, the javascript error is caught and handled within the execution of a deferred Promise, and concluded with a Promise resolve, then there is no Uncaught (in promise) error.

The following code walks through the variations of resolving/rejecting a deferred Promise. (The console needs to be open to see the sequence of processing and the Uncaught (in promise) error.)

  • No error (in the deferred Promise) concluding with a Promise resolve.
  • No error concluding with a Promise reject. (Triggers Uncaught error)
  • A caught error, concluding with a Promise resolve.
  • A caught error, concluding with a Promise reject. (Triggers Uncaught error)

Note that even the originating openModal() call makes use of the Promise catch function, in addition to being wrapped with a try..catch, but this still does not trap the reject.

<html><head>

<style>
#ModalArea { display: none; }
#ModalArea.show { display: block; }
</style>

<script>

class Modal {
  constructor() {
    this.parentNode = document.getElementById( 'ModalArea' );

    this.okay = document.createElement( 'BUTTON' );
    this.okay.innerText = 'Okay - Promise RESOLVE';
    this.parentNode.appendChild( this.okay );
  
    this.cancel = document.createElement( 'BUTTON' );
    this.cancel.innerText = 'Cancel - Promise REJECT';
    this.parentNode.appendChild( this.cancel );
    
    this.cancelThrow1 = document.createElement( 'BUTTON' );
    this.cancelThrow1.innerText = 'Cancel w/Throw - Promise RESOLVE';
    this.parentNode.appendChild( this.cancelThrow1 );
    
    this.cancelThrow2 = document.createElement( 'BUTTON' );
    this.cancelThrow2.innerText = 'Cancel w/Throw - Promise REJECT';
    this.parentNode.appendChild( this.cancelThrow2 );
  }
  
  async show() {
    this.parentNode.classList.add( 'show' );
    
    let modalPromise = new Promise( (resolve, reject) => {
      this.okay.onclick = (event) => {
        resolve( 'Okay via Promise RESOLVE' );
      };
      this.cancel.onclick = ( event ) => {
        reject( 'Cancel via Promise REJECT' );
      };
      this.cancelThrow1.onclick = ( event ) => {
        try {
          throw new Error( 'Throw /catch error concluding with Promise RESOLVE' );
        } catch ( err ) {
          console.log( 'Cancel w/Throw via Promise RESOLVE' );
          resolve( err );
        }
      };
      this.cancelThrow2.onclick = ( event ) => {
        try {
          throw new Error( 'Throw /catch error concluding with Promise resolve' );
        } catch ( err ) {
          console.log( 'Cancel w/Throw via Promise REJECT' );
          reject( err );
        }
      };
    });
    
    modalPromise.catch( e => {
      console.log( 'Promise CATCH fired!' );
    } );
    
    // Clear out the 'modal' buttons after the promise completes.
    modalPromise.finally( () => {
      console.log( 'Promise FINALLY fired!' );
      this.parentNode.innerHTML = '';
    });

    return modalPromise;
  }

}

async function openModal() {

  // Create new modal buttons...
  let modal = new Modal();
  document.getElementById( 'Result' ).innerHTML =  'Result: ';
  
  // Show the buttons, but wait for the promise to resolve...
  try {
    document.getElementById( 'Result' ).innerText += await modal.show();
  } catch( err ) {
    document.getElementById( 'Result' ).innerText += err;
  }
  
  // Now that the promise resolved, append more text to the result.
  document.getElementById( 'Result' ).innerText += ' - Done!';  
}

</script>

</head><body>


<button onclick="
try {
  openModal()
  .then( x => console.log( 'openModal().THEN fired!' ) )
  .catch( x => console.log( 'openModal().CATCH fired!' ) );
} catch( err ) {
  console.log( [ 'openModal() TRY/CATCH fired!', err ] );
}
">Open Modal</button>
<div id='ModalArea'></div>
<div id='Result'></div>
</body></html>

So, I'm concluding (again, possibly erroneously) that a deferred Promise must only be concluded with a resolve if the desire is to avoid the Uncaught (in promise) error. Another apparent option is to incorporate an unhandledrejection event handler, but this just seems to add unnecessary complexity if one can avoid the Promise reject...

EDIT: @hackape's answer points out my flaw

In short, I was not treating then() and finally() as branches of the original promise, that they themselves return a promise! With that in mind, one can take @hackape's example and tweak it to further hold and pass around these branch promises in as squirrely a way as possible, all the while avoiding the Uncaught (in promise) errors.

// Modified version of @hackape's example
function defer() {
  let resolve
  let reject
  const promise = new Promise((rsv, rjt) => {
    resolve = rsv
    reject = rjt
  })

  return {
    promise,
    resolve,
    reject,
  }
}

// 1 uncaught
function case_a( promiseB ) {
  const d = defer()
  d.promise.catch(e => console.log('[catch(a1)]', e))
  d.promise.finally(e => console.log('[finally(a1)]', e)) // uncaught
  d.reject('apple')
  
  // Let's get squirrely.  Create and return the
  // promiseB finally() promise, which is then passed
  // to case_c, which will handle the associated catch!
  let pbFinally = promiseB.promise.finally(e => console.log('[finally(b1)]', e))
  return pbFinally
}

// all caught!
function case_b() {
  const d = defer()
  d.promise.catch(e => console.log('[catch(b1)]', e))
  d.reject('banana')
  return d
}

// 2 uncaught
function case_c( pb ) {
  const d = defer()
  d.promise.catch(e => console.log('[catch(c1)]', e))
  d.promise.finally(e => console.log('[finally(c1)]', e)).catch(e => console.log('[catch(c2)]', e))
  d.promise.finally(e => console.log('[finally(c2)]', e)) // uncaught
  d.promise.finally(e => console.log('[finally(c3)]', e)) // uncaught
  
  // Catch associated with case_b finally(), which prevents
  // the case_b finally() promise from throwing an
  // 'Uncaught (in promise)' error!
  pb.catch(e => console.log('[catch(b2)]', e))
  
  d.reject('cherry')
}

function test() {
  let promiseB = case_b()
  let promiseBFinally = case_a( promiseB )
  case_c( promiseBFinally )
}

test()
Trentium
  • 3,419
  • 2
  • 12
  • 19
  • Honestly the updated example makes little sense to me. You pass `promiseB` created from `case_b` into `case_a` and `_c`, but it never interacts with promiseA or C, why would you pass it around for no purpose? The location of code doesn’t affect execution order. You can simply attach the error handler right inside `case_b` that’s total equivalent. – hackape Apr 19 '21 at 01:52
  • Hmm it seems to me you also have some confusion about the execution order, or schedule mechanism of JS event loop between sync and async tasks. – hackape Apr 19 '21 at 01:54
  • 1
    @hackape Concur, there is no sense in the actual purpose behind the code the way I refactored it(!), other than to exemplify that rather than `promise().then().finally().catch()` being contiguously chained, that it can be broken up into `p=promise(); t=p.then(); f=t.finally(); c=f.catch()` and spread across various functions, without the `Uncaught (in promise)` error rearing. This, at least to me, answers the central question about deferring promises and avoiding said error... – Trentium Apr 19 '21 at 11:32
  • Got it. If you wanna consolidate your understanding of promise, try implement it yourself is the best way. Warning, it's quite a challenge that can take up a whole day, that's what it took me last time I tried. – hackape Apr 19 '21 at 12:16