From 356a3bccb0e7468a2c8ce7d8c9c6cd0c5d436b8b Mon Sep 17 00:00:00 2001 From: Richard Gibson Date: Thu, 14 Apr 2016 23:59:30 -0400 Subject: [PATCH] Deferred: Separate the two paths in jQuery.when Single- and no-argument calls act like Promise.resolve. Multi-argument calls act like Promise.all. Fixes gh-3029 Closes gh-3059 --- build/tasks/promises_aplus_tests.js | 14 +- src/deferred.js | 100 +++-- ....js => promises_aplus_adapter_deferred.js} | 0 test/promises_aplus_adapter_when.js | 51 +++ test/unit/deferred.js | 399 ++++++++++-------- test/unit/effects.js | 2 +- 6 files changed, 341 insertions(+), 225 deletions(-) rename test/{promises_aplus_adapter.js => promises_aplus_adapter_deferred.js} (100%) create mode 100644 test/promises_aplus_adapter_when.js diff --git a/build/tasks/promises_aplus_tests.js b/build/tasks/promises_aplus_tests.js index 3e770a079..c4fb86d4c 100644 --- a/build/tasks/promises_aplus_tests.js +++ b/build/tasks/promises_aplus_tests.js @@ -4,10 +4,20 @@ module.exports = function( grunt ) { var spawnTest = require( "./lib/spawn_test.js" ); - grunt.registerTask( "promises_aplus_tests", function() { + grunt.registerTask( "promises_aplus_tests", + [ "promises_aplus_tests_deferred", "promises_aplus_tests_when" ] ); + + grunt.registerTask( "promises_aplus_tests_deferred", function() { spawnTest( this.async(), "./node_modules/.bin/promises-aplus-tests", - "test/promises_aplus_adapter.js" + "test/promises_aplus_adapter_deferred.js" + ); + } ); + + grunt.registerTask( "promises_aplus_tests_when", function() { + spawnTest( this.async(), + "./node_modules/.bin/promises-aplus-tests", + "test/promises_aplus_adapter_when.js" ); } ); }; diff --git a/src/deferred.js b/src/deferred.js index 0ea1e7f1f..6e4d43b31 100644 --- a/src/deferred.js +++ b/src/deferred.js @@ -13,6 +13,38 @@ function Thrower( ex ) { throw ex; } +function adoptValue( value, resolve, reject ) { + var method; + + try { + + // Check for promise aspect first to privilege synchronous behavior + if ( value && jQuery.isFunction( ( method = value.promise ) ) ) { + method.call( value ).done( resolve ).fail( reject ); + + // Other thenables + } else if ( value && jQuery.isFunction( ( method = value.then ) ) ) { + method.call( value, resolve, reject ); + + // Other non-thenables + } else { + + // Support: Android 4.0 only + // Strict mode functions invoked without .call/.apply get global-object context + resolve.call( undefined, value ); + } + + // For Promises/A+, convert exceptions into rejections + // Since jQuery.when doesn't unwrap thenables, we can skip the extra checks appearing in + // Deferred#then to conditionally suppress rejection. + } catch ( /*jshint -W002 */ value ) { + + // Support: Android 4.0 only + // Strict mode functions invoked without .call/.apply get global-object context + reject.call( undefined, value ); + } +} + jQuery.extend( { Deferred: function( func ) { @@ -305,67 +337,45 @@ jQuery.extend( { }, // Deferred helper - when: function() { - var method, resolveContexts, - i = 0, + when: function( singleValue ) { + var + + // count of uncompleted subordinates + remaining = arguments.length, + + // count of unprocessed arguments + i = remaining, + + // subordinate fulfillment data + resolveContexts = Array( i ), resolveValues = slice.call( arguments ), - length = resolveValues.length, - // the count of uncompleted subordinates - remaining = length, - - // the master Deferred. + // the master Deferred master = jQuery.Deferred(), - // Update function for both resolving subordinates + // subordinate callback factory updateFunc = function( i ) { return function( value ) { resolveContexts[ i ] = this; resolveValues[ i ] = arguments.length > 1 ? slice.call( arguments ) : value; if ( !( --remaining ) ) { - master.resolveWith( - resolveContexts.length === 1 ? resolveContexts[ 0 ] : resolveContexts, - resolveValues - ); + master.resolveWith( resolveContexts, resolveValues ); } }; }; - // Add listeners to promise-like subordinates; treat others as resolved - if ( length > 0 ) { - resolveContexts = new Array( length ); - for ( ; i < length; i++ ) { + // Single- and empty arguments are adopted like Promise.resolve + if ( remaining <= 1 ) { + adoptValue( singleValue, master.resolve, master.reject ); - // jQuery.Deferred - treated specially to get resolve-sync behavior - if ( resolveValues[ i ] && - jQuery.isFunction( ( method = resolveValues[ i ].promise ) ) ) { - - method.call( resolveValues[ i ] ) - .done( updateFunc( i ) ) - .fail( master.reject ); - - // Other thenables - } else if ( resolveValues[ i ] && - jQuery.isFunction( ( method = resolveValues[ i ].then ) ) ) { - - method.call( - resolveValues[ i ], - updateFunc( i ), - master.reject - ); - } else { - - // Support: Android 4.0 only - // Strict mode functions invoked without .call/.apply get global-object context - updateFunc( i ).call( undefined, resolveValues[ i ] ); - } - } - - // If we're not waiting on anything, resolve the master - } else { - master.resolveWith(); + // Use .then() to unwrap secondary thenables (cf. gh-3000) + return master.then(); } + // Multiple arguments are aggregated like Promise.all array elements + while ( i-- ) { + adoptValue( resolveValues[ i ], updateFunc( i ), master.reject ); + } return master.promise(); } } ); diff --git a/test/promises_aplus_adapter.js b/test/promises_aplus_adapter_deferred.js similarity index 100% rename from test/promises_aplus_adapter.js rename to test/promises_aplus_adapter_deferred.js diff --git a/test/promises_aplus_adapter_when.js b/test/promises_aplus_adapter_when.js new file mode 100644 index 000000000..0a5ec6756 --- /dev/null +++ b/test/promises_aplus_adapter_when.js @@ -0,0 +1,51 @@ +/* jshint node: true */ + +"use strict"; + +require( "jsdom" ).env( "", function( errors, window ) { + if ( errors ) { + console.error( errors ); + return; + } + + var jQuery = require( ".." )( window ); + + exports.deferred = function() { + var adopted, promised, + obj = { + resolve: function() { + if ( !adopted ) { + adopted = jQuery.when.apply( jQuery, arguments ); + if ( promised ) { + adopted.then( promised.resolve, promised.reject ); + } + } + return adopted; + }, + reject: function( value ) { + if ( !adopted ) { + adopted = jQuery.when( jQuery.Deferred().reject( value ) ); + if ( promised ) { + adopted.then( promised.resolve, promised.reject ); + } + } + return adopted; + }, + + // A manually-constructed thenable that works even if calls precede resolve/reject + promise: { + then: function() { + if ( !adopted ) { + if ( !promised ) { + promised = jQuery.Deferred(); + } + return promised.then.apply( promised, arguments ); + } + return adopted.then.apply( adopted, arguments ); + } + } + }; + + return obj; + }; +} ); diff --git a/test/unit/deferred.js b/test/unit/deferred.js index 305740fa4..830103eeb 100644 --- a/test/unit/deferred.js +++ b/test/unit/deferred.js @@ -761,11 +761,30 @@ QUnit.test( "jQuery.Deferred - notify and resolve", function( assert ) { } ); } ); -QUnit.test( "jQuery.when", function( assert ) { - assert.expect( 37 ); +QUnit.test( "jQuery.when(nonThenable) - like Promise.resolve", function( assert ) { + "use strict"; + + assert.expect( 44 ); + + var + + // Support: Android 4.0 only + // Strict mode functions invoked without .call/.apply get global-object context + defaultContext = (function getDefaultContext() { return this; }).call(), + + done = assert.async( 20 ); + + jQuery.when() + .done( function( resolveValue ) { + assert.strictEqual( resolveValue, undefined, "Resolved .done with no arguments" ); + assert.strictEqual( this, defaultContext, "Default .done context with no arguments" ); + } ) + .then( function( resolveValue ) { + assert.strictEqual( resolveValue, undefined, "Resolved .then with no arguments" ); + assert.strictEqual( this, defaultContext, "Default .then context with no arguments" ); + } ); - // Some other objects jQuery.each( { "an empty string": "", "a non-empty string": "some string", @@ -778,51 +797,136 @@ QUnit.test( "jQuery.when", function( assert ) { "a plain object": {}, "an array": [ 1, 2, 3 ] }, function( message, value ) { - assert.ok( - jQuery.isFunction( - jQuery.when( value ).done( function( resolveValue ) { - assert.strictEqual( this, window, "Context is the global object with " + message ); - assert.strictEqual( resolveValue, value, "Test the promise was resolved with " + message ); - } ).promise - ), - "Test " + message + " triggers the creation of a new Promise" - ); - } ); - - assert.ok( - jQuery.isFunction( - jQuery.when().done( function( resolveValue ) { - assert.strictEqual( this, window, "Test the promise was resolved with window as its context" ); - assert.strictEqual( resolveValue, undefined, "Test the promise was resolved with no parameter" ); - } ).promise - ), - "Test calling when with no parameter triggers the creation of a new Promise" - ); - - var cache, - context = {}; - - jQuery.when( jQuery.Deferred().resolveWith( context ) ).done( function() { - assert.strictEqual( this, context, "when( promise ) propagates context" ); - } ); - - jQuery.each( [ 1, 2, 3 ], function( k, i ) { - jQuery.when( cache || jQuery.Deferred( function() { - this.resolve( i ); - } ) - ).done( function( value ) { - assert.strictEqual( value, 1, "Function executed" + ( i > 1 ? " only once" : "" ) ); - cache = value; - } ); + var code = "jQuery.when( " + message + " )", + onFulfilled = function( method ) { + var call = code + "." + method; + return function( resolveValue ) { + assert.strictEqual( resolveValue, value, call + " resolve" ); + assert.strictEqual( this, defaultContext, call + " context" ); + done(); + }; + }, + onRejected = function( method ) { + var call = code + "." + method; + return function() { + assert.ok( false, call + " reject" ); + done(); + }; + }; + jQuery.when( value ) + .done( onFulfilled( "done" ) ) + .fail( onRejected( "done" ) ) + .then( onFulfilled( "then" ), onRejected( "then" ) ); } ); } ); -QUnit.test( "jQuery.when - joined", function( assert ) { +QUnit.test( "jQuery.when(thenable) - like Promise.resolve", function( assert ) { + "use strict"; - assert.expect( 81 ); + assert.expect( 56 ); - var deferreds = { + var slice = [].slice, + sentinel = { context: "explicit" }, + eventuallyFulfilled = jQuery.Deferred().notify( true ), + eventuallyRejected = jQuery.Deferred().notify( true ), + inputs = { + promise: Promise.resolve( true ), + rejectedPromise: Promise.reject( false ), + deferred: jQuery.Deferred().resolve( true ), + eventuallyFulfilled: eventuallyFulfilled, + secondaryFulfilled: jQuery.Deferred().resolve( eventuallyFulfilled ), + multiDeferred: jQuery.Deferred().resolve( "foo", "bar" ), + deferredWith: jQuery.Deferred().resolveWith( sentinel, [ true ] ), + multiDeferredWith: jQuery.Deferred().resolveWith( sentinel, [ "foo", "bar" ] ), + rejectedDeferred: jQuery.Deferred().reject( false ), + eventuallyRejected: eventuallyRejected, + secondaryRejected: jQuery.Deferred().resolve( eventuallyRejected ), + multiRejectedDeferred: jQuery.Deferred().reject( "baz", "quux" ), + rejectedDeferredWith: jQuery.Deferred().rejectWith( sentinel, [ false ] ), + multiRejectedDeferredWith: jQuery.Deferred().rejectWith( sentinel, [ "baz", "quux" ] ) + }, + contexts = { + deferredWith: sentinel, + multiDeferredWith: sentinel, + rejectedDeferredWith: sentinel, + multiRejectedDeferredWith: sentinel + }, + willSucceed = { + promise: [ true ], + deferred: [ true ], + eventuallyFulfilled: [ true ], + secondaryFulfilled: [ true ], + multiDeferred: [ "foo", "bar" ], + deferredWith: [ true ], + multiDeferredWith: [ "foo", "bar" ] + }, + willError = { + rejectedPromise: [ false ], + rejectedDeferred: [ false ], + eventuallyRejected: [ false ], + secondaryRejected: [ false ], + multiRejectedDeferred: [ "baz", "quux" ], + rejectedDeferredWith: [ false ], + multiRejectedDeferredWith: [ "baz", "quux" ] + }, + + // Support: Android 4.0 only + // Strict mode functions invoked without .call/.apply get global-object context + defaultContext = (function getDefaultContext() { return this; }).call(), + + done = assert.async( 28 ); + + jQuery.each( inputs, function( message, value ) { + var code = "jQuery.when( " + message + " )", + shouldResolve = willSucceed[ message ], + shouldError = willError[ message ], + context = contexts[ message ] || defaultContext, + onFulfilled = function( method ) { + var call = code + "." + method; + return function() { + if ( shouldResolve ) { + assert.deepEqual( slice.call( arguments ), shouldResolve, + call + " resolve" ); + assert.strictEqual( this, context, call + " context" ); + } else { + assert.ok( false, call + " resolve" ); + } + done(); + }; + }, + onRejected = function( method ) { + var call = code + "." + method; + return function() { + if ( shouldError ) { + assert.deepEqual( slice.call( arguments ), shouldError, call + " reject" ); + assert.strictEqual( this, context, call + " context" ); + } else { + assert.ok( false, call + " reject" ); + } + done(); + }; + }; + + jQuery.when( value ) + .done( onFulfilled( "done" ) ) + .fail( onRejected( "done" ) ) + .then( onFulfilled( "then" ), onRejected( "then" ) ); + } ); + + setTimeout( function() { + eventuallyFulfilled.resolve( true ); + eventuallyRejected.reject( false ); + }, 50 ); +} ); + +QUnit.test( "jQuery.when(a, b) - like Promise.all", function( assert ) { + "use strict"; + + assert.expect( 196 ); + + var slice = [].slice, + deferreds = { rawValue: 1, fulfilled: jQuery.Deferred().resolve( 1 ), rejected: jQuery.Deferred().reject( 0 ), @@ -842,46 +946,91 @@ QUnit.test( "jQuery.when - joined", function( assert ) { eventuallyRejected: true, rejectedStandardPromise: true }, - counter = 49, // Support: Android 4.0 only // Strict mode functions invoked without .call/.apply get global-object context - expectedContext = (function() { "use strict"; return this; }).call(); + defaultContext = (function getDefaultContext() { return this; }).call(), - QUnit.stop(); + done = assert.async( 98 ); - function restart() { - if ( !--counter ) { - QUnit.start(); - } - } - - jQuery.each( deferreds, function( id1, defer1 ) { - jQuery.each( deferreds, function( id2, defer2 ) { - var shouldResolve = willSucceed[ id1 ] && willSucceed[ id2 ], + jQuery.each( deferreds, function( id1, v1 ) { + jQuery.each( deferreds, function( id2, v2 ) { + var code = "jQuery.when( " + id1 + ", " + id2 + " )", + shouldResolve = willSucceed[ id1 ] && willSucceed[ id2 ], shouldError = willError[ id1 ] || willError[ id2 ], - expected = shouldResolve ? [ 1, 1 ] : [ 0, undefined ], - code = "jQuery.when( " + id1 + ", " + id2 + " )"; + expected = shouldResolve ? [ 1, 1 ] : [ 0 ], + context = shouldResolve ? [ defaultContext, defaultContext ] : defaultContext, + onFulfilled = function( method ) { + var call = code + "." + method; + return function() { + if ( shouldResolve ) { + assert.deepEqual( slice.call( arguments ), expected, + call + " resolve" ); + assert.deepEqual( this, context, code + " context" ); + } else { + assert.ok( false, call + " resolve" ); + } + done(); + }; + }, + onRejected = function( method ) { + var call = code + "." + method; + return function() { + if ( shouldError ) { + assert.deepEqual( slice.call( arguments ), expected, call + " reject" ); + assert.deepEqual( this, context, code + " context" ); + } else { + assert.ok( false, call + " reject" ); + } + done(); + }; + }; - jQuery.when( defer1, defer2 ).done( function( a, b ) { - if ( shouldResolve ) { - assert.deepEqual( [ a, b ], expected, code + " => resolve" ); - assert.strictEqual( this[ 0 ], expectedContext, code + " => context[0] OK" ); - assert.strictEqual( this[ 1 ], expectedContext, code + " => context[1] OK" ); - } else { - assert.ok( false, code + " => resolve" ); - } - } ).fail( function( a, b ) { - if ( shouldError ) { - assert.deepEqual( [ a, b ], expected, code + " => reject" ); - } else { - assert.ok( false, code + " => reject" ); - } - } ).always( restart ); + jQuery.when( v1, v2 ) + .done( onFulfilled( "done" ) ) + .fail( onRejected( "done" ) ) + .then( onFulfilled( "then" ), onRejected( "then" ) ); + } ); + } ); + + setTimeout( function() { + deferreds.eventuallyFulfilled.resolve( 1 ); + deferreds.eventuallyRejected.reject( 0 ); + }, 50 ); +} ); + +QUnit.test( "jQuery.when - always returns a new promise", function( assert ) { + + assert.expect( 42 ); + + jQuery.each( { + "no arguments": [], + "non-thenable": [ "foo" ], + "promise": [ Promise.resolve( "bar" ) ], + "rejected promise": [ Promise.reject( "bar" ) ], + "deferred": [ jQuery.Deferred().resolve( "baz" ) ], + "rejected deferred": [ jQuery.Deferred().reject( "baz" ) ], + "multi-resolved deferred": [ jQuery.Deferred().resolve( "qux", "quux" ) ], + "multiple non-thenables": [ "corge", "grault" ], + "multiple deferreds": [ + jQuery.Deferred().resolve( "garply" ), + jQuery.Deferred().resolve( "waldo" ) + ] + }, function( label, args ) { + var result = jQuery.when.apply( jQuery, args ); + + assert.ok( jQuery.isFunction( result.then ), "Thenable returned from " + label ); + assert.strictEqual( result.resolve, undefined, "Non-deferred returned from " + label ); + assert.strictEqual( result.promise(), result, "Promise returned from " + label ); + + jQuery.each( args, function( i, arg ) { + assert.notStrictEqual( result, arg, "Returns distinct from arg " + i + " of " + label ); + if ( arg.promise ) { + assert.notStrictEqual( result, arg.promise(), + "Returns distinct from promise of arg " + i + " of " + label ); + } } ); } ); - deferreds.eventuallyFulfilled.resolve( 1 ); - deferreds.eventuallyRejected.reject( 0 ); } ); QUnit.test( "jQuery.when - notify does not affect resolved", function( assert ) { @@ -900,107 +1049,3 @@ QUnit.test( "jQuery.when - notify does not affect resolved", function( assert ) assert.ok( false, "Error on resolve" ); } ); } ); - -QUnit.test( "jQuery.when - filtering", function( assert ) { - - assert.expect( 2 ); - - function increment( x ) { - return x + 1; - } - - QUnit.stop(); - - jQuery.when( - jQuery.Deferred().resolve( 3 ).then( increment ), - jQuery.Deferred().reject( 5 ).then( null, increment ) - ).done( function( four, six ) { - assert.strictEqual( four, 4, "resolved value incremented" ); - assert.strictEqual( six, 6, "rejected value incremented" ); - QUnit.start(); - } ); -} ); - -QUnit.test( "jQuery.when - exceptions", function( assert ) { - - assert.expect( 2 ); - - function woops() { - throw "exception thrown"; - } - - QUnit.stop(); - - jQuery.Deferred().resolve().then( woops ).fail( function( doneException ) { - assert.strictEqual( doneException, "exception thrown", "throwing in done handler" ); - jQuery.Deferred().reject().then( null, woops ).fail( function( failException ) { - assert.strictEqual( failException, "exception thrown", "throwing in fail handler" ); - QUnit.start(); - } ); - } ); -} ); - -QUnit.test( "jQuery.when - chaining", function( assert ) { - - assert.expect( 4 ); - - var defer = jQuery.Deferred(); - - function chain() { - return defer; - } - - function chainStandard() { - return Promise.resolve( "std deferred" ); - } - - QUnit.stop(); - - jQuery.when( - jQuery.Deferred().resolve( 3 ).then( chain ), - jQuery.Deferred().reject( 5 ).then( null, chain ), - jQuery.Deferred().resolve( 3 ).then( chainStandard ), - jQuery.Deferred().reject( 5 ).then( null, chainStandard ) - ).done( function( v1, v2, s1, s2 ) { - assert.strictEqual( v1, "other deferred", "chaining in done handler" ); - assert.strictEqual( v2, "other deferred", "chaining in fail handler" ); - assert.strictEqual( s1, "std deferred", "chaining thenable in done handler" ); - assert.strictEqual( s2, "std deferred", "chaining thenable in fail handler" ); - QUnit.start(); - } ); - - defer.resolve( "other deferred" ); -} ); - -QUnit.test( "jQuery.when - solitary thenables", function( assert ) { - - assert.expect( 1 ); - - var done = assert.async(), - rejected = new Promise( function( resolve, reject ) { - setTimeout( function() { - reject( "rejected" ); - }, 100 ); - } ); - - jQuery.when( rejected ).then( - function() { - assert.ok( false, "Rejected, solitary, non-Deferred thenable should not resolve" ); - done(); - }, - function() { - assert.ok( true, "Rejected, solitary, non-Deferred thenable rejected properly" ); - done(); - } - ); -} ); - -QUnit.test( "jQuery.when does not reuse a solitary jQuery Deferred (gh-2018)", function( assert ) { - - assert.expect( 2 ); - var defer = jQuery.Deferred().resolve(), - promise = jQuery.when( defer ); - - assert.equal( promise.state(), "resolved", "Master Deferred is immediately resolved" ); - assert.notStrictEqual( defer.promise(), promise, "jQuery.when returns the master deferred's promise" ); -} ); diff --git a/test/unit/effects.js b/test/unit/effects.js index 5f1913575..2b953cd35 100644 --- a/test/unit/effects.js +++ b/test/unit/effects.js @@ -1028,7 +1028,7 @@ jQuery.each( { jQuery( elem ).remove(); } ); - this.clock.tick( 50 ); + this.clock.tick( 100 ); } ); } ); } );