3

I've put together an example to demonstrate what I'm getting at:

function onInput(ev) {
  let term = ev.target.value;
  console.log(`searching for "${term}"`);
  getSearchResults(term).then(results => {
    console.log(`results for "${term}"`,results);
  });
}

function getSearchResults(term) {
  return new Promise((resolve,reject) => {
    let timeout = getRandomIntInclusive(100,2000);
    setTimeout(() => {
       resolve([term.toLowerCase(), term.toUpperCase()]);  
    }, timeout);
    
  });
}

function getRandomIntInclusive(min, max) {
  min = Math.ceil(min);
  max = Math.floor(max);
  return Math.floor(Math.random() * (max - min + 1)) + min;
}
<input onInput="onInput(event)">

Type in the "search" box and watch the console. The search results come back out of order!

How can we cancel any pending promises when there's new input and guarantee the results come back in order?

mpen
  • 272,448
  • 266
  • 850
  • 1,236
  • You might be interested in a [debounce function](https://davidwalsh.name/javascript-debounce-function). Doesn't answer the ordering question though – Phil Dec 31 '16 at 05:53
  • The ordering issue is due to your timeout randomisation. Later requests may resolve before earlier ones – Phil Dec 31 '16 at 05:55
  • @Phil Yes, I understand *why* it's happening, I'm asking what's a good way to deal with it? The random timeout is to simulate how long a network request might take. – mpen Dec 31 '16 at 05:57
  • You'll need to maintain some state outside your functions, aborting prior requests (`clearTimeout` if you're still using `setTimeout`) on new search information – Phil Dec 31 '16 at 06:02
  • This might help ~ http://stackoverflow.com/questions/30233302/promise-is-it-possible-to-force-cancel-a-promise – Phil Dec 31 '16 at 06:03
  • 1
    @mpen You should definitely take a look at [RxJS](https://github.com/Reactive-Extensions/RxJS). It's a library, which solves problems exactly like this one in few lines of code. It is built on concepts of observables. You might think it's just another fancy library for specific scenarios, but observables are useful for many sorts of problems. They are part of ECMAScript proposals (currently stage 1) as well. – Erik Cupal Jan 01 '17 at 17:24

5 Answers5

3

Instead of using debounce, or timeouts, I set a small amount of state outside inside (suggestion by Jaromanda X) of this function that uses a referenced function. This way, you can just change the function reference to something like a noop. The promise still resolves, but it won't take any action. However, the final one will not have changed its function reference:

var onInput = function() {
  let logger = function(term, results) {
    console.log(`results for "${term}"`, results);
  };
  let noop = Function.prototype;
  let lastInstance = null;

  function ActionManager(action) {
    this.action = action;
  }

  return function onInput(ev) {
    let term = ev.target.value;
    console.log(`searching for "${term}"`);

    if (lastInstance) {
      lastInstance.action = noop;
    }

    let inst = new ActionManager(logger.bind(null, term));
    lastInstance = inst;

    getSearchResults(term).then(response => inst.action(response));
  }
}();



/****************************************
 * The rest of the JavaScript is included only for simulation purposes
 ****************************************/

function getSearchResults(term) {
  return new Promise((resolve, reject) => {
    let timeout = getRandomIntInclusive(100, 2000);
    setTimeout(() => {
      resolve([term.toLowerCase(), term.toUpperCase()]);
    }, timeout);

  });
}

function getRandomIntInclusive(min, max) {
  min = Math.ceil(min);
  max = Math.floor(max);
  return Math.floor(Math.random() * (max - min + 1)) + min;
}
<input onInput="onInput(event)">
KevBot
  • 17,900
  • 5
  • 50
  • 68
  • I like this answer, but as logger, noop, lastInstance and AcrionManager are all only relevant inside `onInput` - you could make the `state` encolsed like in [this fiddle](https://jsfiddle.net/3sfqdp8y/) – Jaromanda X Dec 31 '16 at 09:26
  • @JaromandaX, good point. I've updated with your suggestion. Thanks! – KevBot Dec 31 '16 at 16:54
3

You can use Promise.race to cancel the effect of a previous chain:

let cancel = () => {};

function onInput(ev) {
  let term = ev.target.value;
  console.log(`searching for "${term}"`);
  cancel();
  let p = new Promise(resolve => cancel = resolve);
  Promise.race([p, getSearchResults(term)]).then(results => {
    if (results) {
      console.log(`results for "${term}"`,results);
    }
  });
}

function getSearchResults(term) {
  return new Promise(resolve => {
    let timeout = 100 + Math.floor(Math.random() * 1900);
    setTimeout(() => resolve([term.toLowerCase(), term.toUpperCase()]), timeout);
  });
}
<input onInput="onInput(event)">

Here we're doing it by injecting an undefined result and testing for it.

jib
  • 40,579
  • 17
  • 100
  • 158
  • That's a really interesting approach. Haven't seen someone pull out the `resolve` method like that. I kinda like it! – mpen Jan 02 '17 at 20:00
1

One workable solution is to include a latestTimestamp and simply ignore any responses that come in with an early timestamp (and and therefore obsolete).

let latestTimestamp = 0;

function onInput(ev) {
  let term = ev.target.value;
  console.log(`searching for "${term}"`);
  latestTimestamp = Date.now();
  getSearchResults(term, latestTimestamp).then(results => {
    if (results[2] !== latestTimestamp) {
      console.log("Ignoring old answer");
    } else {
      console.log(`results for "${term}"`, results);
    }
  });
}

function getSearchResults(term, latestTimestamp) {
  return new Promise((resolve, reject) => {
    let timeout = getRandomIntInclusive(100, 2000);
    setTimeout(() => {
      resolve([term.toLowerCase(), term.toUpperCase(), latestTimestamp]);
    }, timeout);

  });
}

function getRandomIntInclusive(min, max) {
  min = Math.ceil(min);
  max = Math.floor(max);
  return Math.floor(Math.random() * (max - min + 1)) + min;
}
<input onInput="onInput(event)">
Jeremy J Starcher
  • 23,369
  • 6
  • 54
  • 74
  • I think a simple counter would work better than a timestamp (no chance of dupes if 2 requests go out in the same ms), but the idea is solid. – mpen Dec 31 '16 at 18:00
  • @mpen Yea, you're right -- a simple counter would be better in this case. (last time I did something similar, we needed the timestamp so my head was kinda stuck here.) – Jeremy J Starcher Dec 31 '16 at 19:02
-1

You shouldn't use setTimeout's in promises the way you are doing it, because from the .then you are returning the callback from the .setTimeout() which would not work and mess up the order. To make the promises go in order you should make a function like shown below:

function wait(n){
    return new Promise(function(resolve){
        setTimeout(resolve, n)
    });
}

and substitute the setTimeout()'s with that function like shown below:

wait(getRandomIntInclusive(100,2000)).then(function(){
    // code
});
  • See the comments under the question, the timeout is used to simulate network response time variation. –  Dec 31 '16 at 06:11
-1

You can use async package - a bunch of utilities to maintain asynchronous code. It was first developed for node.js but it can also be used in frontend.
You need series function, it saves an order of promises. Here is a brief example in coffeescript:

async.series([
  ->
    ### do some stuff ###
    Q 'one'
  ->
    ### do some more stuff ... ###
    Q 'two'
]).then (results) ->
    ### results is now equal to ['one', 'two'] ###
    doStuff()
  .done()

### an example using an object instead of an array ###
async.series({
  one: -> Q.delay(200).thenResolve(1)
  two: -> Q.delay(100).thenResolve(2)
}).then (results) ->
    ### results is now equal to: {one: 1, two: 2} ###
    doStuff()
  .done()  

See caolan.github.io/async/