Opened 10 years ago

Closed 10 years ago

#9311 closed defect (fixed)

Deferred: Callback chain interrupted, unexpected result

Reported by: Jarrod Carlson Owned by: James Burke
Priority: high Milestone: 1.4
Component: Core Version: 1.3.0
Keywords: deferred Cc:
Blocked By: Blocking:

Description

I have a dojo.xhr request going on, and one of the callbacks attached to the deferred calls some code that ultimately adds another callback to the original deferred object.

The result of this is that execution of the callback chain is interrupted and the new callback is immediately fired with the original result from the xhr.

Perhaps this is best explained with a code sample. For simplicity (and to sandbox the problem), I have emulated dojo.xhr behavior.

function fakeIO() {

    var io = new dojo.Deferred();
    
    io.addCallback(function(dfd) {
        // Parses plain-text into JS Object, like dojo.xhr.
        // This should be the first callback in the chain to act
        return eval('(' + dfd.ioArgs.xhr.responseText + ')');
    });
    
    setTimeout(function() {
        // After some mock server delay, JSON is returned
        dojo.setObject("ioArgs.xhr.responseText", '{"foo":"bar","baz":"qux"}', io);
        
        // Like dojo.xhr, the callback is initiated with itself
        io.callback(io);
    }, 500);
    
    return io;
    
}

function doFoo(obj) {
    if (obj instanceof dojo.Deferred) {
        console.warn("Uh oh... why did I get a Deferred object?!?");
    }
    else {
        console.log("obj.foo = " + obj.foo);
    }
}

var dfd = fakeIO();
dfd.addBoth(function(obj) {
    // For some reason or another, my only job as a callback is
    // to add another callback to the chain. Unfortunately, the
    // function I'm adding will interrupt the flow of the chain,
    // executing immediately with the original result the 
    // Deferred received (io, in this case, a Deferred object).
    dfd.addCallback(doFoo);
    
    return obj;
});

So let's walk through it. A 'fake' dojo.xhr function is provided, fakeIO(). This function, like dojo.xhr, returns a dojo.Deferred instance with some pre-processing callbacks attached. Great. Also, a timeout is scheduled to kick-off the callback chain.

Also like dojo.xhr, the callback chain initially starts with the Deferred instance itself as the callback. Normally, this is not a problem, since the first callback in the chain parses the XmlHttpRequest? response, and execution continues.

Next, I've created a handler of my own, doFoo. It does whatever normal developers might do with Ajax results.

Next, we actually make a (fake) xhr request by calling fakeIO(). We also attach a callback that will do stuff, and then add another callback to the chain.

If you run this code, you'll notice that the doFoo() function does NOT get the JavaScript? object it expects - it receives the original fakeIO Deferred!

This odd behavior occurs because Deferred.addCallback doesn't bother to check if callback processing is already underway (source:/dojo/trunk/_base/Deferred.js#L354). Also, the Deferred instance does not update its result cache until the callback chain has been depleted (source:/dojo/trunk/_base/Deferred.js#L411).

If processing is already in progress, the new callback (or errback) should be simply appended to the chain and allow processing to resume.

Alternatively, if this is by design, the new callback should probably receive the most recent result from the chain, not the original result.

Change History (7)

comment:1 Changed 10 years ago by Jarrod Carlson

A more practical scenario where this occurs in my code might also help illustrate the problem.

I have a form that users can fill out. The form includes some dijit.form.FilteringSelect? widgets tied to a custom store that I have written. The custom store loosely models dojox.data.JsonRestStore? in that data is retrieved on-demand from the server.

To cut-down on unnecessary trips to the server, a cross-cutting AOP-style aspect is layered on top of the store itself, caching the Deferred instance generated when requests are actually made. By doing this, further requests for the same data can be handled locally by simply appending a new callback to the Deferred chain.

The solution works well in testing, but in practice, the FilteringSelect? boxes make quite a few hits on the store. Under the right circumstances, (e.g. source:/dijit/trunk/form/FilteringSelect.js#L133), cached Deferred instances can get new callbacks added to the chain before the original chain finishes (as described in my post above).

For the moment, I've worked around this problem by using a setTimeout on a delay of 0ms to append the next callback (which forces it to wait until the current chain has completely depleted).

Still, this little issue caused me several hours of infuriating confusion trying to track it down, so hopefully someone else will agree with my logic here...

comment:2 Changed 10 years ago by James Burke

So a simplified test of this problem would be to have a Deferred, have code register a callback on it, and in that callback, register a second callback.

So in that situation, the second callback gets the value that originally triggered the callbacks, but not the return value of the first callback?

comment:3 Changed 10 years ago by James Burke

Milestone: tbdfuture

comment:4 Changed 10 years ago by Jarrod Carlson

That sounds like a pretty straight forward explanation of the problem, yes.

comment:5 Changed 10 years ago by James Burke

Milestone: future1.4
Owner: changed from anonymous to James Burke

Not sure if there is some hidden gotcha with Deferreds that expect this behavior, but I have a fix, and the Deferred and DeferredList? tests pass with the fix.

comment:6 Changed 10 years ago by James Burke

Hmm, no svn sync. Fixed in [20522], "nested addCallback calls end up with the nested callback not getting the right, up to date response."

comment:7 Changed 10 years ago by James Burke

Resolution: fixed
Status: newclosed
Note: See TracTickets for help on using tickets.