jenkins-bot has submitted this change and it was merged. Change subject: Use cross-origin img attribute instead of data URI ......................................................................
Use cross-origin img attribute instead of data URI After lots of experimenting with Wireshark and current Chrome + Firefox on Ubuntu 13.10, this is my current understanding of the caching when preloading images with AJAX requests: * on Chrome, the image request always comes from browser cache * Firefox makes two separate requests by default * Firefox with img.crossOrigin = 'anonymous' makes two separate requests, but the second one is a 304 (does not load the image twice) * when the image has already been cached by the browser (but not in this session), Chrome skips both requests; Firefox skips the AJAX request, but sends the normal one, and it returns with 304. "wish I knew this when I started" things: * the Chrome DevTools has an option to disable cache. When this is enabled, requests in the same document context still come from cache (so if I load the page, fire an AJAX request, then without reloading the page, fire an AJAX request to the same URL, then the second request will be cached), but an AJAX request - image request pair is an exception from this. * when using Ctrl-F5 in Firefox, requests on that page will never hit the cache (even AJAX request fired after user activity; even if two identical requests follow each other). When using clear cache + normal reload, this is not the case. * if the image does not have an Allow-Origin header and is loaded with crossOrigin=true, Firefox will refuse to load it. Chrome will log an error in the console saying it refused to load it, but will actually load it. * Wireshark rocks. Pushed some tech debt (browser + domain whitelist) into other tickets: https://wikimedia.mingle.thoughtworks.com/projects/multimedia/cards/232 https://wikimedia.mingle.thoughtworks.com/projects/multimedia/cards/233 Reverted commits: 8a8d74f01d3dbd6d0c43b7fadc5284d204091761. 63021d0b0e95442cce101f9f92de8f0ff97d5f49. Change-Id: I84ab2f3ac0a9706926adf7fe8726ecd9e9f843e0 Bug: 61542 Mingle: https://wikimedia.mingle.thoughtworks.com/projects/multimedia/cards/207 --- M resources/mmv/mmv.performance.js M resources/mmv/provider/mmv.provider.Image.js M tests/browser/features/step_definitions/basic_mmv_navigation_steps.rb M tests/qunit/mmv.performance.test.js M tests/qunit/provider/mmv.provider.Image.test.js 5 files changed, 206 insertions(+), 128 deletions(-) Approvals: Gilles: Looks good to me, approved jenkins-bot: Verified diff --git a/resources/mmv/mmv.performance.js b/resources/mmv/mmv.performance.js index 5c98f3e..a6b6403 100755 --- a/resources/mmv/mmv.performance.js +++ b/resources/mmv/mmv.performance.js @@ -29,12 +29,6 @@ P = Performance.prototype; /** - * How long to wait to ensure window.performance is populated - * @property {number} - */ - P.delay = 1000; - - /** * Global setup that should be done while the page loads */ P.init = function () { @@ -53,39 +47,30 @@ * cached by the browser, as it will consume unnecessary bandwidth for the user. * @param {string} type the type of request to be measured * @param {string} url URL to be measured - * @param {string} responseType responseType for the XHR options * @returns {jQuery.Promise} A promise that resolves when the contents of the URL have been fetched */ - P.record = function ( type, url, responseType ) { + P.record = function ( type, url ) { var deferred = $.Deferred(), request, perf = this, start; - request = this.newXHR(); - - request.onreadystatechange = function () { - var total = $.now() - start; - - if ( request.readyState === 4 ) { - deferred.resolve( request.response ); - - perf.recordEntryDelayed( type, total, url, request ); - } - }; - - start = $.now(); - try { + request = this.newXHR(); + request.onreadystatechange = function () { + var total = $.now() - start; + + if ( request.readyState === 4 ) { + deferred.resolve( request.response ); + perf.recordEntryDelayed( type, total, url, request ); + } + }; + + start = $.now(); request.open( 'GET', url, true ); - - if ( responseType !== undefined ) { - request.responseType = responseType; - } - request.send(); } catch ( e ) { - // This happens on old browsers that don't support CORS + // old browser not supporting XMLHttpRequest or CORS, or CORS is not permitted return deferred.reject(); } @@ -123,7 +108,7 @@ return; } - // Don't record entries that hit the browser cache on undetailed requests + // Don't record entries that hit the browser cache if ( total !== undefined && total < 1 ) { return; } @@ -322,7 +307,7 @@ // it hasn't been added yet at this point setTimeout( function() { perf.recordEntry( type, total, url, request ); - }, this.delay ); + }, 0 ); }; /** @@ -427,4 +412,5 @@ new Performance().init(); mw.mmv.Performance = Performance; + }( mediaWiki, jQuery ) ); diff --git a/resources/mmv/provider/mmv.provider.Image.js b/resources/mmv/provider/mmv.provider.Image.js index 3abb4af..ee9a2f9 100644 --- a/resources/mmv/provider/mmv.provider.Image.js +++ b/resources/mmv/provider/mmv.provider.Image.js @@ -38,43 +38,26 @@ /** * Loads an image and returns it. + * Includes performance metrics. * @param {string} url * @return {jQuery.Promise.<HTMLImageElement>} a promise which resolves to the image object */ Image.prototype.get = function ( url ) { - var deferred, start, - img = new window.Image(), + var provider = this, cacheKey = url, - provider = this; + start, + rawGet; if ( !this.cache[cacheKey] ) { - deferred = $.Deferred(); - this.cache[cacheKey] = deferred.promise(); - - img.onload = function() { - // Start is only defined for old browsers - if ( start !== undefined ) { - provider.performance.recordEntry( 'image', $.now() - start ); - } - deferred.resolve( img ); - }; - - img.onerror = function() { - deferred.reject( 'could not load image from ' + url ); - }; - - // We can only measure detailed image performance on browsers that are capable of - // reading the XHR binary results as data URI. Otherwise loading the image as XHR - // and then assigning the same URL to an image's src attribute - // would cause a double request (won't hit browser cache). - if ( this.browserSupportsBinaryOperations() ) { - this.performance.record( 'image', url, 'arraybuffer' ).then( function( response ) { - img.src = provider.binaryToDataURI( response ); - } ); + if ( this.imagePreloadingSupported() ) { + rawGet = $.proxy( provider.rawGet, provider, url, true ); + this.cache[cacheKey] = this.performance.record( 'image', url ).then( rawGet, rawGet ); } else { - // On old browsers we just do oldschool timing without details start = $.now(); - img.src = url; + this.cache[cacheKey] = this.rawGet( url ); + this.cache[cacheKey].always( function () { + provider.performance.recordEntry( 'image', $.now() - start, url ); + } ); } } @@ -82,34 +65,46 @@ }; /** - * Converts a binary image into a data URI - * @param {string} binary Binary image - * @returns {string} base64-encoded data URI representing the image + * Internal version of get(): no caching, no performance metrics. + * @param {string} url + * @param {boolean} [cors] if true, use CORS for preloading + * @return {jQuery.Promise.<HTMLImageElement>} a promise which resolves to the image object */ - Image.prototype.binaryToDataURI = function ( binary ) { - var i, - bytes = new Uint8Array( binary ), - raw = ''; + Image.prototype.rawGet = function ( url, cors ) { + var img = new window.Image(), + deferred = $.Deferred(); - for ( i = 0; i < bytes.length; i++ ) { - raw += String.fromCharCode( bytes[ i ] ); + // On Firefox this will trigger a CORS request for the image, instead of a normal one, + // and allows using the image in a canvas etc. + // We don't really care about that, but it seems the request will share cache with the + // AJAX requests this way, so we can do AJAX preloading. + // On other browsers hopefully it will have the same effect or at least won't make + // things worse. + // FIXME the image won't load of there is no Allowed-Origin header! we will want a + // whitelist for this. + if ( cors ) { + img.crossOrigin = 'anonymous'; } - // I've tested this on Firefox, Chrome, IE10, IE11, Safari, Opera - // and for all of them we can get away with not giving the proper mime type - // If we run into a browser where that's a problem, we'll need - // to read the binary contents to determine the mime type - return 'data:image;base64,' + btoa( raw ); + img.onload = function() { + deferred.resolve( img ); + }; + img.onerror = function() { + deferred.reject( 'could not load image from ' + url ); + }; + + img.src = url; + + return deferred; }; /** - * Checks if the browser is capable of converting binary content to a data URI - * @returns {boolean} + * Checks whether the current browser supports AJAX preloading of images. + * @return {boolean} */ - Image.prototype.browserSupportsBinaryOperations = function () { - return window.btoa !== undefined && - window.Uint8Array !== undefined && - String.fromCharCode !== undefined; + Image.prototype.imagePreloadingSupported = function () { + // FIXME this is a *very* rough guess, but it'll work as the first estimation. + return 'crossOrigin' in new Image(); }; mw.mmv.provider.Image = Image; diff --git a/tests/browser/features/step_definitions/basic_mmv_navigation_steps.rb b/tests/browser/features/step_definitions/basic_mmv_navigation_steps.rb index fd831b0..664f7b0 100644 --- a/tests/browser/features/step_definitions/basic_mmv_navigation_steps.rb +++ b/tests/browser/features/step_definitions/basic_mmv_navigation_steps.rb @@ -69,7 +69,7 @@ page.mmv_image_div_element.should be_visible # Check image content - page.mmv_image_div_element.image_element.attribute('src').should match /Kerala|^data:image;base64,.+gBeRmlsZSB/ + page.mmv_image_div_element.image_element.attribute('src').should match /Kerala/ # Check basic metadata is present @@ -112,7 +112,7 @@ page.mmv_image_div_element.should be_visible # Check image content - page.mmv_image_div_element.image_element.attribute('src').should match /Offsite|^data:image;base64,.+gB4Rmls/ + page.mmv_image_div_element.image_element.attribute('src').should match /Offsite/ # Check basic metadata is present diff --git a/tests/qunit/mmv.performance.test.js b/tests/qunit/mmv.performance.test.js index 6a938d5..5f5e27d 100644 --- a/tests/qunit/mmv.performance.test.js +++ b/tests/qunit/mmv.performance.test.js @@ -30,6 +30,24 @@ }; } + function createFakeXHR( response ) { + return { + readyState: 0, + open: $.noop, + send: function () { + var xhr = this; + + setTimeout( function () { + xhr.readyState = 4; + xhr.response = response; + if ( $.isFunction( xhr.onreadystatechange ) ) { + xhr.onreadystatechange(); + } + }, 0 ); + } + }; + } + QUnit.test( 'recordEntry: basic', 5, function ( assert ) { var performance = new mw.mmv.Performance(), oldEventLog = mw.eventLog, @@ -235,6 +253,42 @@ assert.ok( $.isEmptyObject( varnishXCache ), 'Varnish cache results are empty' ); } ); + QUnit.test( 'record()', 4, function ( assert ) { + var type = 'foo', + url = 'http://example.com/', + response = {}, + performance = new mw.mmv.Performance(); + + performance.newXHR = function () { return createFakeXHR( response ); }; + + QUnit.stop(); + performance.recordEntryDelayed = function ( recordType, _, recordUrl, recordRequest ) { + assert.strictEqual( recordType, type, 'type is recorded correctly' ); + assert.strictEqual( recordUrl, url, 'url is recorded correctly' ); + assert.strictEqual( recordRequest.response, response, 'response is recorded correctly' ); + QUnit.start(); + }; + + QUnit.stop(); + performance.record( type, url ).done( function ( recordResponse ) { + assert.strictEqual( recordResponse, response, 'response is passed to callback' ); + QUnit.start(); + } ); + } ); + + QUnit.asyncTest( 'record() with old browser', 1, function ( assert ) { + var type = 'foo', + url = 'http://example.com/', + performance = new mw.mmv.Performance(); + + performance.newXHR = function () { throw 'XMLHttpRequest? What\'s that?'; }; + + performance.record( type, url ).fail( function () { + assert.ok( true, 'the promise is rejected when XMLHttpRequest is not supported' ); + QUnit.start(); + } ); + } ); + QUnit.test( 'mw.mmv.Api', 3, function ( assert ) { var api, oldRecord = mw.mmv.Performance.prototype.recordJQueryEntryDelayed, diff --git a/tests/qunit/provider/mmv.provider.Image.test.js b/tests/qunit/provider/mmv.provider.Image.test.js index 16dbfe7..eae5754 100644 --- a/tests/qunit/provider/mmv.provider.Image.test.js +++ b/tests/qunit/provider/mmv.provider.Image.test.js @@ -16,22 +16,6 @@ */ ( function ( mw, $ ) { - var i, - binary, - dataURI = 'data:image;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0' - + 'iAAAABlBMVEUAAAD///+l2Z/dAAAAM0lEQVR4nGP4/5/h/1+G/58ZDrAz3D/McH' - + '8yw83NDDeNGe4Ug9C9zwz3gVLMDA/A6P9/AFGGFyjOXZtQAAAAAElFTkSuQmCC', - hex = '89504e470d0a1a0a0000000d4948445200000010000000100103000000253d6d' - + '2200000006504c5445000000ffffffa5d99fdd0000003349444154789c63f8ff' - + '9fe1ff5f86ff9f190eb033dc3fcc707f32c3cdcd0c378d19ee1483d0bdcf0cf7' - + '8152cc0c0fc0e8ff7f0051861728ce5d9b500000000049454e44ae426082'; - - binary = new Uint8Array( hex.length / 2 ); - - for ( i = 0; i < hex.length; i += 2 ) { - binary[ i / 2 ] = parseInt( hex.substr( i, 2 ), 16 ); - } - QUnit.module( 'mmv.provider.Image', QUnit.newMwEnvironment() ); QUnit.test( 'Image constructor sanity check', 1, function ( assert ) { @@ -40,54 +24,113 @@ assert.ok( imageProvider ); } ); - QUnit.test( 'Image load success test', 5, function ( assert ) { - var imageProvider = new mw.mmv.provider.Image(), - oldPerformance = imageProvider.performance, - fakeURL = 'fakeURL'; + QUnit.test( 'Image load success test', 2, function ( assert ) { + var url = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0' + + 'iAAAABlBMVEUAAAD///+l2Z/dAAAAM0lEQVR4nGP4/5/h/1+G/58ZDrAz3D/McH' + + '8yw83NDDeNGe4Ug9C9zwz3gVLMDA/A6P9/AFGGFyjOXZtQAAAAAElFTkSuQmCC', + imageProvider = new mw.mmv.provider.Image(); - imageProvider.performance.delay = 0; - - imageProvider.performance.newXHR = function () { - return { readyState: 4, - response: binary, - send: function () { this.onreadystatechange(); }, - open: $.noop }; + imageProvider.performance = { + imagePreloadingSupported: function () { return false; }, + recordEntry: $.noop }; QUnit.stop(); - imageProvider.performance.recordEntry = function ( type, total, url ) { - assert.strictEqual( type, 'image', 'Type matches' ); - assert.ok( total < 10, 'Total is less than 10ms' ); - assert.strictEqual( url, fakeURL, 'URL matches' ); - - QUnit.start(); - - imageProvider.performance = oldPerformance; - - return $.Deferred().resolve(); - }; - - QUnit.stop(); - imageProvider.get( fakeURL ).then( function( image ) { + imageProvider.get( url ).then( function( image ) { assert.ok( image instanceof HTMLImageElement, 'success handler was called with the image element'); - assert.strictEqual( image.src, dataURI ); + assert.strictEqual( image.src, url, 'image src is correct'); QUnit.start(); } ); + } ); + + QUnit.test( 'Image caching test', 6, function ( assert ) { + var url = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0' + + 'iAAAABlBMVEUAAAD///+l2Z/dAAAAM0lEQVR4nGP4/5/h/1+G/58ZDrAz3D/McH' + + '8yw83NDDeNGe4Ug9C9zwz3gVLMDA/A6P9/AFGGFyjOXZtQAAAAAElFTkSuQmCC', + url2 = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', + result, + imageProvider = new mw.mmv.provider.Image(); + + imageProvider.performance = { + imagePreloadingSupported: function () { return false; }, + recordEntry: $.noop + }; + + QUnit.stop(); + imageProvider.get( url ).then( function( image ) { + result = image; + assert.ok( image instanceof HTMLImageElement, + 'success handler was called with the image element'); + assert.strictEqual( image.src, url, 'image src is correct'); + QUnit.start(); + } ); + + QUnit.stop(); + imageProvider.get( url ).then( function( image ) { + assert.strictEqual( image, result, 'image element is cached and not regenerated' ); + assert.strictEqual( image.src, url, 'image src is correct'); + QUnit.start(); + } ); + + QUnit.stop(); + imageProvider.get( url2 ).then( function( image ) { + assert.notStrictEqual( image, result, 'image element for different url is not cached' ); + assert.strictEqual( image.src, url2, 'image src is correct'); + QUnit.start(); + } ); + } ); QUnit.asyncTest( 'Image load fail test', 1, function ( assert ) { var imageProvider = new mw.mmv.provider.Image(); + imageProvider.performance = { + imagePreloadingSupported: function () { return false; }, + recordEntry: $.noop + }; + imageProvider.get( 'doesntexist.png' ).fail( function() { - assert.ok( true, 'fail handler was called' ); - QUnit.start(); - } ); + assert.ok( true, 'fail handler was called' ); + QUnit.start(); + } ); } ); - QUnit.test( 'binaryToDataURI', 1, function ( assert ) { - var imageProvider = new mw.mmv.provider.Image(); + QUnit.test( 'Image load test with preloading supported', 1, function ( assert ) { + var url = mw.config.get( 'wgScriptPath' ) + '/skins/vector/images/search-ltr.png', + imageProvider = new mw.mmv.provider.Image(), + endsWith = function ( a, b ) { return a.indexOf( b ) === a.length - b.length; }; - assert.strictEqual( imageProvider.binaryToDataURI( binary ), dataURI, 'Binary is correctly converted to data URI' ); + imageProvider.imagePreloadingSupported = function () { return true; }; + imageProvider.performance = { + record: function() { return $.Deferred().resolve(); } + }; + + QUnit.stop(); + imageProvider.get( url ).done( function( image ) { + // can't test equality as browsers transform this to a full URL + assert.ok( endsWith( image.src, url ), 'local image loaded with correct source'); + QUnit.start(); + } ).fail( function () { + // do not hold up the tests if the image failed to load + assert.ok( false, 'uh-oh, couldnt load - might be a problem with the test installation' ); + QUnit.start(); + } ); + } ); + + QUnit.test( 'Failed image load test with preloading supported', 1, function ( assert ) { + var url = 'nosuchimage.png', + imageProvider = new mw.mmv.provider.Image(); + + imageProvider.imagePreloadingSupported = function () { return true; }; + imageProvider.performance = { + record: function() { return $.Deferred().resolve(); } + }; + + QUnit.stop(); + imageProvider.get( url ).fail( function () { + assert.ok( true, 'Fail callback called for non-existing image' ); + QUnit.start(); + } ); } ); }( mediaWiki, jQuery ) ); -- To view, visit https://gerrit.wikimedia.org/r/114403 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I84ab2f3ac0a9706926adf7fe8726ecd9e9f843e0 Gerrit-PatchSet: 11 Gerrit-Project: mediawiki/extensions/MultimediaViewer Gerrit-Branch: master Gerrit-Owner: Gergő Tisza <gti...@wikimedia.org> Gerrit-Reviewer: Aarcos <aarcos.w...@gmail.com> Gerrit-Reviewer: Gergő Tisza <gti...@wikimedia.org> Gerrit-Reviewer: Gilles <gdu...@wikimedia.org> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits