Repository: openseadragon/openseadragon
Pull request #2718: Raster conversion moved from jobs to conversion pipeline.
Author: login: Aiosa
Created: 2025-04-29T08:27:00Z | Merged: 2025-05-28T16:40:41Z

Individual changes described in comments below.


Diff:
diff --git a/Gruntfile.js b/Gruntfile.js
index a3a1d4fac..167158ecc 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -51,7 +51,7 @@ module.exports = function(grunt) {
             "src/imagetilesource.js",
             "src/tilesourcecollection.js",
             "src/priorityqueue.js",
-            "src/datatypeconvertor.js",
+            "src/datatypeconverter.js",
             "src/button.js",
             "src/buttongroup.js",
             "src/rectangle.js",
diff --git a/changelog.txt b/changelog.txt
index 1613b1d66..f964040a6 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -8,10 +8,10 @@ OPENSEADRAGON CHANGELOG
     * DEPRECATION: TileSource data lifecycle handlers: system manages these automatically: TileSource.[createTileCache|destroyTileCache|getTileCacheData|getTileCacheDataAsImage|getTileCacheDataAsContext2D]
     * Tiles data is driven by caches: tiles can have multiple caches and cache can reference multiple tiles.
     * Data types & conversion pipeline: caches support automated conversion between types, and call optionally destructors. These are asynchronous.
-    * Data conversion reasoning: the system keeps costs of convertors and seeks the cheapest conversion to a target format (using Dijkstra).
+    * Data conversion reasoning: the system keeps costs of converters and seeks the cheapest conversion to a target format (using Dijkstra).
     * Async support: events can now await handlers. Added OpenSeadragon.Promise proxy object. This object supports also synchronous mode.
     * Drawers define what data they are able to work with, and receive automatically data from one of the declared types.
-    * Drawers now store data only inside cache, and provide optional type convertors to move data into a format they can work with.
+    * Drawers now store data only inside cache, and provide optional type converters to move data into a format they can work with.
     * TileSource equality operator. TileSource destructor support. TileSources now must output type of the data they download [context.finish].
     * Zombies: data can outlive tiles, and be kept in the system to wait if they are not suddenly needed. Turned on automatically with TiledImage addition with `replace: true` and equality test success.
     * ImagesLoadedPerFrame is boosted 10 times when system is fresh (reset / open) and then declines back to original value.
diff --git a/src/datatypeconvertor.js b/src/datatypeconverter.js
similarity index 80%
rename from src/datatypeconvertor.js
rename to src/datatypeconverter.js
index 3683a7154..d0cd221f6 100644
--- a/src/datatypeconvertor.js
+++ b/src/datatypeconverter.js
@@ -1,5 +1,5 @@
 /*
- * OpenSeadragon.convertor (static property)
+ * OpenSeadragon.converter (static property)
  *
  * Copyright (C) 2009 CodePlex Foundation
  * Copyright (C) 2010-2025 OpenSeadragon contributors
@@ -151,7 +151,7 @@ class WeightedGraph {
  * Edge.transform function on the conversion path in OpenSeadragon.converter.getConversionPath().
  *  It can be also conversion to undefined if used as destructor implementation.
  *
- * @callback TypeConvertor
+ * @callback TypeConverter
  * @memberof OpenSeadragon
  * @param {OpenSeadragon.Tile} tile reference tile that owns the data
  * @param {any} data data in the input format
@@ -178,16 +178,37 @@ class WeightedGraph {
  * @param {OpenSeadragon.PriorityQueue.Node} origin - Origin node of the conversion step.
  *  Its value is the origin format.
  * @param {number} weight cost of the conversion
- * @param {TypeConvertor} transform the conversion itself
+ * @param {TypeConverter} transform the conversion itself
  */
 
 /**
  * Class that orchestrates automated data types conversion. Do not instantiate
- * this class, use OpenSeadragon.convertor - a global instance, instead.
- * @class DataTypeConvertor
+ * this class, use OpenSeadragon.converter - a global instance, instead.
+ *
+ * Types are defined to closely describe the data type, e.g. "url" is insufficient,
+ * because url can point to many different data types. Another bad example is 'canvas'
+ * as canvas can have different underlying rendering implementations and thus differ
+ * in behavior. The following data types supported by
+ * OpenSeadragon core are:
+ * - "image" - HTMLImageElement, an <image> object
+ * - "context2d" - HtmlRenderingContext2D, a 2D canvas context
+ * - "imageUrl" - string, a URL to a resource carrying image data
+ * - "rasterBlob" - Blob, a binary file-like object carrying image data
+ *
+ * The system uses these to deliver desired data from TileSource (which implements fetching logics)
+ * through plugins to the renderer with preserving data type compatibility. Typical example is:
+ *  TiledImage downloads without ajax a data present at url 'myUrl'. It submits
+ *  to the system object of data type 'imageUrl'. The system runs this object through
+ *  possible plugins integrated into the invalidation routine (by default none),
+ *  and finishes by conversion for the WebGL renderer, which would most likely be "image"
+ *  object, because the conversion is the cheapest starting from "imageUrl" type.
+ *  If some plugin required context2d type, the pipeline would deliver this type and used
+ *  it also for WebGL, as texture loading function accepts canvas object as well as image.
+ *
+ * @class DataTypeConverter
  * @memberOf OpenSeadragon
  */
-$.DataTypeConvertor = class {
+$.DataTypeConverter = class {
 
     constructor() {
         this.graph = new WeightedGraph();
@@ -202,10 +223,13 @@ $.DataTypeConvertor = class {
             const img = new Image();
             img.onerror = img.onabort = reject;
             img.onload = () => resolve(img);
+            if (tile.tiledImage && tile.tiledImage.crossOriginPolicy) {
+                img.crossOrigin = tile.tiledImage.crossOriginPolicy;
+            }
             img.src = url;
         });
         const canvasContextCreator = (tile, imageData) => {
-            const canvas = document.createElement( 'canvas' );
+            const canvas = document.createElement('canvas');
             canvas.width = imageData.width;
             canvas.height = imageData.height;
             const context = canvas.getContext('2d', { willReadFrequently: true });
@@ -213,15 +237,38 @@ $.DataTypeConvertor = class {
             return context;
         };
 
-        this.learn("context2d", "webImageUrl", (tile, ctx) => ctx.canvas.toDataURL(), 1, 2);
-        this.learn("image", "webImageUrl", (tile, image) => image.url);
+        this.learn("rasterBlob", "image", (tile, blob) => new $.Promise((resolve, reject) => {
+            // eslint-disable-next-line compat/compat
+            const url = (window.URL || window.webkitURL).createObjectURL(blob);
+            if (!$.supportsAsync) {
+                reject("Not supported in sync mode!");
+            }
+            const img = new Image();
+            img.onerror = img.onabort = reject;
+            img.onload = () => {
+                // eslint-disable-next-line compat/compat
+                (window.URL || window.webkitURL).revokeObjectURL(blob);
+                resolve(img);
+            };
+            img.src = url;
+        }));
+        this.learn("context2d", "rasterBlob", (tile, ctx) => new $.Promise((resolve, reject) => {
+            if (!$.supportsAsync) {
+                reject("Not supported in sync mode!");
+            }
+            ctx.canvas.toBlob(resolve);
+        }));
+
+        this.learn("context2d", "imageUrl", (tile, ctx) => ctx.canvas.toDataURL(), 1, 2);
+        this.learn("image", "imageUrl", (tile, image) => image.url);
         this.learn("image", "context2d", canvasContextCreator, 1, 1);
-        this.learn("url", "image", imageCreator, 1, 1);
+        this.learn("imageUrl", "image", imageCreator, 1, 1);
 
         //Copies
         this.learn("image", "image", (tile, image) => imageCreator(tile, image.src), 1, 1);
-        this.learn("url", "url", (tile, url) => url, 0, 1); //strings are immutable, no need to copy
+        this.learn("imageUrl", "imageUrl", (tile, url) => url, 0, 1); //strings are immutable, no need to copy
         this.learn("context2d", "context2d", (tile, ctx) => canvasContextCreator(tile, ctx.canvas));
+        this.learn("rasterBlob", "rasterBlob", (tile, blob) => blob, 0, 1); //blobs are immutable, no need to copy
 
         /**
          * Free up canvas memory
@@ -287,7 +334,7 @@ $.DataTypeConvertor = class {
      * Teach the system to convert data type 'from' -> 'to'
      * @param {string} from unique ID of the data item 'from'
      * @param {string} to unique ID of the data item 'to'
-     * @param {OpenSeadragon.TypeConvertor} callback convertor that takes two arguments: a tile reference, and
+     * @param {OpenSeadragon.TypeConverter} callback converter that takes two arguments: a tile reference, and
      *  a data object of a type 'from'; and converts this data object to type 'to'. It can return also the value
      *  wrapped in a Promise (returned in resolve) or it can be async function.
      * @param {Number} [costPower=0] positive cost class of the conversion, smaller or equal than 7.
@@ -301,8 +348,8 @@ $.DataTypeConvertor = class {
      *   use costPower=2, costMultiplier=3; can be between 1 and 10^5
      */
     learn(from, to, callback, costPower = 0, costMultiplier = 1) {
-        $.console.assert(costPower >= 0 && costPower <= 7, "[DataTypeConvertor] Conversion costPower must be between <0, 7>.");
-        $.console.assert($.isFunction(callback), "[DataTypeConvertor:learn] Callback must be a valid function!");
+        $.console.assert(costPower >= 0 && costPower <= 7, "[DataTypeConverter] Conversion costPower must be between <0, 7>.");
+        $.console.assert($.isFunction(callback), "[DataTypeConverter:learn] Callback must be a valid function!");
 
         if (from === to) {
             this.copyings[to] = callback;
@@ -345,7 +392,7 @@ $.DataTypeConvertor = class {
     convert(tile, data, from, ...to) {
         const conversionPath = this.getConversionPath(from, to);
         if (!conversionPath) {
-            $.console.error(`[OpenSeadragon.convertor.convert] Conversion ${from} ---> ${to} cannot be done!`);
+            $.console.error(`[OpenSeadragon.converter.convert] Conversion ${from} ---> ${to} cannot be done!`);
             return $.Promise.resolve();
         }
 
@@ -358,7 +405,7 @@ $.DataTypeConvertor = class {
             let edge = conversionPath[i];
             let y = edge.transform(tile, x);
             if (y === undefined) {
-                $.console.error(`[OpenSeadragon.convertor.convert] data mid result undefined value (while converting using %s)`, edge);
+                $.console.error(`[OpenSeadragon.converter.convert] data mid result undefined value (while converting using %s)`, edge);
                 return $.Promise.resolve();
             }
             //node.value holds the type string
@@ -385,7 +432,7 @@ $.DataTypeConvertor = class {
             const y = copyTransform(tile, data);
             return $.type(y) === "promise" ? y : $.Promise.resolve(y);
         }
-        $.console.warn(`[OpenSeadragon.convertor.copy] is not supported with type %s`, type);
+        $.console.warn(`[OpenSeadragon.converter.copy] is not supported with type %s`, type);
         return $.Promise.resolve(undefined);
     }
 
@@ -412,13 +459,13 @@ $.DataTypeConvertor = class {
      * @return {ConversionStep[]|undefined} array of required conversions (returns empty array
      *  for from===to), or undefined if the system cannot convert between given types.
      *  Each object has 'transform' function that converts between neighbouring types, such
-     *  that x = arr[i].transform(x) is valid input for convertor arr[i+1].transform(), e.g.
+     *  that x = arr[i].transform(x) is valid input for converter arr[i+1].transform(), e.g.
      *  arr[i+1].transform(arr[i].transform( ... )) is a valid conversion procedure.
      *
      *  Note: if a function is returned, it is a callback called once the data is ready.
      */
     getConversionPath(from, to) {
-        let bestConvertorPath, selectedType;
+        let bestConverterPath, selectedType;
         let knownFrom = this._known[from];
         if (!knownFrom) {
             this._known[from] = knownFrom = {};
@@ -435,22 +482,22 @@ $.DataTypeConvertor = class {
             for (const outType of to) {
                 const conversion = knownFrom[outType];
                 if (conversion && bestCost > conversion.cost) {
-                    bestConvertorPath = conversion;
+                    bestConverterPath = conversion;
                     bestCost = conversion.cost;
                     selectedType = outType;
                 }
             }
         } else {
             $.console.assert(typeof to === "string", "[getConversionPath] conversion 'to' type must be defined.");
-            bestConvertorPath = knownFrom[to];
+            bestConverterPath = knownFrom[to];
             selectedType = to;
         }
 
-        if (!bestConvertorPath) {
-            bestConvertorPath = this.graph.dijkstra(from, selectedType);
-            this._known[from][selectedType] = bestConvertorPath;
+        if (!bestConverterPath) {
+            bestConverterPath = this.graph.dijkstra(from, selectedType);
+            this._known[from][selectedType] = bestConverterPath;
         }
-        return bestConvertorPath ? bestConvertorPath.path : undefined;
+        return bestConverterPath ? bestConverterPath.path : undefined;
     }
 
     /**
@@ -462,7 +509,7 @@ $.DataTypeConvertor = class {
     }
 
     /**
-     * Check whether given type is known to the convertor
+     * Check whether given type is known to the converter
      * @param {string} type type to test
      * @return {boolean}
      */
@@ -472,7 +519,7 @@ $.DataTypeConvertor = class {
 };
 
 /**
- * Static convertor available throughout OpenSeadragon.
+ * Static converter available throughout OpenSeadragon.
  *
  * Built-in conversions include types:
  *  - context2d    canvas 2d context
@@ -480,9 +527,9 @@ $.DataTypeConvertor = class {
  *  - url    url string carrying or pointing to 2D raster data
  *  - canvas       HTMLCanvas element
  *
- * @type OpenSeadragon.DataTypeConvertor
+ * @type OpenSeadragon.DataTypeConverter
  * @memberOf OpenSeadragon
  */
-$.convertor = new $.DataTypeConvertor();
+$.converter = new $.DataTypeConverter();
 
 }(OpenSeadragon));
diff --git a/src/htmldrawer.js b/src/htmldrawer.js
index 68a59c0bc..c29dbdf0b 100644
--- a/src/htmldrawer.js
+++ b/src/htmldrawer.js
@@ -86,11 +86,11 @@ class HTMLDrawer extends OpenSeadragon.DrawerBase{
         }
 
         // The actual placing logics will not happen at draw event, but when the cache is created:
-        $.convertor.learn("context2d", HTMLDrawer.canvasCacheType, (t, d) => _prepareTile(t, d.canvas), 1, 1);
-        $.convertor.learn("image", HTMLDrawer.imageCacheType, _prepareTile, 1, 1);
+        $.converter.learn("context2d", HTMLDrawer.canvasCacheType, (t, d) => _prepareTile(t, d.canvas), 1, 1);
+        $.converter.learn("image", HTMLDrawer.imageCacheType, _prepareTile, 1, 1);
         // Also learn how to move back, since these elements can be just used as-is
-        $.convertor.learn(HTMLDrawer.canvasCacheType, "context2d", (t, d) => d.data.getContext('2d'), 1, 3);
-        $.convertor.learn(HTMLDrawer.imageCacheType, "image", (t, d) => d.data, 1, 3);
+        $.converter.learn(HTMLDrawer.canvasCacheType, "context2d", (t, d) => d.data.getContext('2d'), 1, 3);
+        $.converter.learn(HTMLDrawer.imageCacheType, "image", (t, d) => d.data, 1, 3);
 
         function _freeTile(data) {
             if ( data.imgElement && data.imgElement.parentNode ) {
@@ -101,8 +101,8 @@ class HTMLDrawer extends OpenSeadragon.DrawerBase{
             }
         }
 
-        $.convertor.learnDestroy(HTMLDrawer.canvasCacheType, _freeTile);
-        $.convertor.learnDestroy(HTMLDrawer.imageCacheType, _freeTile);
+        $.converter.learnDestroy(HTMLDrawer.canvasCacheType, _freeTile);
+        $.converter.learnDestroy(HTMLDrawer.imageCacheType, _freeTile);
     }
 
     static get imageCacheType() {
diff --git a/src/tile.js b/src/tile.js
index 3fcda03c2..5ecf682cd 100644
--- a/src/tile.js
+++ b/src/tile.js
@@ -555,16 +555,16 @@ $.Tile.prototype = {
             if (typeof data === 'function') {
                 $.console.error("[TileCache.cacheTile] options.data as a callback requires type argument! Current is " + type);
             }
-            type = $.convertor.guessType(data);
+            type = $.converter.guessType(data);
         }
 
         const overwritesMainCache = key === this.cacheKey;
         if (_safely && (overwritesMainCache || setAsMain)) {
             // Need to get the supported type for rendering out of the active drawer.
             const supportedTypes = tiledImage.viewer.drawer.getSupportedDataFormats();
-            const conversion = $.convertor.getConversionPath(type, supportedTypes);
+            const conversion = $.converter.getConversionPath(type, supportedTypes);
             $.console.assert(conversion, "[Tile.addCache] data was set for the default tile cache we are unable" +
-                `to render. Make sure OpenSeadragon.convertor was taught to convert ${type} to (one of): ${conversion.toString()}`);
+                `to render. Make sure OpenSeadragon.converter was taught to convert ${type} to (one of): ${conversion.toString()}`);
         }
 
         const cachedItem = tiledImage._tileCache.cacheTile({
@@ -613,9 +613,9 @@ $.Tile.prototype = {
             if (overwritesMainCache || setAsMain) {
                 // Need to get the supported type for rendering out of the active drawer.
                 const supportedTypes = tiledImage.viewer.drawer.getSupportedDataFormats();
-                const conversion = $.convertor.getConversionPath(cache.type, supportedTypes);
+                const conversion = $.converter.getConversionPath(cache.type, supportedTypes);
                 $.console.assert(conversion, "[Tile.setCache] data was set for the default tile cache we are unable" +
-                    `to render. Make sure OpenSeadragon.convertor was taught to convert ${cache.type} to (one of): ${conversion.toString()}`);
+                    `to render. Make sure OpenSeadragon.converter was taught to convert ${cache.type} to (one of): ${conversion.toString()}`);
             }
         }
 
diff --git a/src/tilecache.js b/src/tilecache.js
index 79c87d40b..2693f4578 100644
--- a/src/tilecache.js
+++ b/src/tilecache.js
@@ -66,7 +66,7 @@
 
         /**
          * Read the cache type. The type can dynamically change, but should be consistent at
-         * one point in the time. For available types see the OpenSeadragon.Convertor, or the tutorials.
+         * one point in the time. For available types see the OpenSeadragon.Converter, or the tutorials.
          * @returns {string}
          */
         get type() {
@@ -130,7 +130,7 @@
         getDataAs(type = undefined, copy = true) {
             if (this.loaded) {
                 if (type === this._type) {
-                    return copy ? $.convertor.copy(this._tRef, this._data, type || this._type) : this._promise;
+                    return copy ? $.converter.copy(this._tRef, this._data, type || this._type) : this._promise;
                 }
                 return this._transformDataIfNeeded(this._tRef, this._data, type || this._type, copy) || this._promise;
             }
@@ -145,14 +145,14 @@
 
             let result;
             if (type !== this._type) {
-                result = $.convertor.convert(referenceTile, data, this._type, type);
+                result = $.converter.convert(referenceTile, data, this._type, type);
             } else if (copy) { //convert does not copy data if same type, do explicitly
-                result = $.convertor.copy(referenceTile, data, type);
+                result = $.converter.copy(referenceTile, data, type);
             }
             if (result) {
                 return result.then(finalData => {
                     if (this._destroyed) {
-                        $.convertor.destroy(finalData, type);
+                        $.converter.destroy(finalData, type);
                         return undefined;
                     }
                     return finalData;
@@ -465,7 +465,7 @@
                     this._destroySelfUnsafe(this._data, this._type);
                 } else if (this._promise) {
                     const oldType = this._type;
-                    this._promise.then(x => this._destroySelfUnsafe(x, oldType));
+                    this._promise.then(x => this._destroySelfUnsafe(x, oldType)).catch($.console.error);
                 }
             }
 
@@ -473,7 +473,7 @@
 
         _destroySelfUnsafe(data, type) {
             // ensure old data destroyed
-            $.convertor.destroy(data, type);
+            $.converter.destroy(data, type);
             this.destroyInternalCache();
             // might've got revived in meanwhile if async ...
             if (!this._destroyed) {
@@ -613,7 +613,7 @@
         _overwriteData(data, type) {
             if (this._destroyed) {
                 //we have received the ownership of the data, destroy it too since we are destroyed
-                $.convertor.destroy(data, type);
+                $.converter.destroy(data, type);
                 return $.Promise.resolve();
             }
             if (this.loaded) {
@@ -621,7 +621,7 @@
                 if (this._data === data && this._type === type) {
                     return this._promise;
                 }
-                $.convertor.destroy(this._data, this._type);
+                $.converter.destroy(this._data, this._type);
                 this._type = type;
                 this._data = data;
                 this._promise = $.Promise.resolve(data);
@@ -639,7 +639,7 @@
                 if (this._data === data && this._type === type) {
                     return this._data;
                 }
-                $.convertor.destroy(this._data, this._type);
+                $.converter.destroy(this._data, this._type);
                 this._type = type;
                 this._data = data;
                 this._promise = $.Promise.resolve(data);
@@ -661,8 +661,8 @@
          * @private
          */
         _convert(from, to) {
-            const convertor = $.convertor,
-                conversionPath = convertor.getConversionPath(from, to);
+            const converter = $.converter,
+                conversionPath = converter.getConversionPath(from, to);
             if (!conversionPath) {
                 $.console.error(`[CacheRecord._convert] Conversion ${from} ---> ${to} cannot be done!`);
                 return; //no-op
@@ -684,7 +684,7 @@
                         _this.loaded = false;
                         throw `[CacheRecord._convert] data mid result undefined value (while converting using ${edge}})`;
                     }
-                    convertor.destroy(x, edge.origin.value);
+                    converter.destroy(x, edge.origin.value);
                     const result = $.type(y) === "promise" ? y : $.Promise.resolve(y);
                     return result.then(res => convert(res, i + 1));
                 };
@@ -897,7 +897,7 @@
                     $.console.error("[TileCache.cacheTile] options.dataType is mandatory " +
                         " when data item is a callback!");
                 }
-                options.dataType = $.convertor.guessType(options.data);
+                options.dataType = $.converter.guessType(options.data);
             }
 
             cacheRecord.addTile(theTile, options.data, options.dataType);
diff --git a/src/tiledimage.js b/src/tiledimage.js
index e8f9eb1ee..4ab4aff7a 100644
--- a/src/tiledimage.js
+++ b/src/tiledimage.js
@@ -1499,11 +1499,14 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
 
         // Load the new 'best' n tiles
         if (bestTiles && bestTiles.length > 0) {
-            for (let tile of bestTiles) {
-                if (tile) {
-                    this._loadTile(tile, currentTime);
+            // Avoid executing this in the frame loop
+            setTimeout(() => {
+                for (let tile of bestTiles) {
+                    if (tile) {
+                        this._loadTile(tile, currentTime);
+                    }
                 }
-            }
+            });
             this._needsDraw = true;
             return false;
         } else {
@@ -1798,7 +1801,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
 
         });
 
-        return best;
+        return best || [];
     },
 
     /**
@@ -2223,6 +2226,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
         tile.tiledImage = this; //unloaded with tile.unload(), so we need to set it back
         // does nothing if tile.cacheKey already present
 
+        $.console.assert(dataType !== undefined, "TileSource::downloadTileStart must return a dataType.");
+
         let tileCacheCreated = false;
         tile.addCache(tile.cacheKey, () => {
             tileCacheCreated = true;
diff --git a/src/tilesource.js b/src/tilesource.js
index 50ce76aae..ae81be13b 100644
--- a/src/tilesource.js
+++ b/src/tilesource.js
@@ -758,11 +758,11 @@ $.TileSource.prototype = {
     tileExists: function( level, x, y ) {
         var numTiles = this.getNumTiles( level );
         return level >= this.minLevel &&
-               level <= this.maxLevel &&
-               x >= 0 &&
-               y >= 0 &&
-               x < numTiles.x &&
-               y < numTiles.y;
+            level <= this.maxLevel &&
+            x >= 0 &&
+            y >= 0 &&
+            x < numTiles.x &&
+            y < numTiles.y;
     },
 
     /**
@@ -806,32 +806,10 @@ $.TileSource.prototype = {
      * @param {string} [context.errorMsg] Private parameter. The final error message, default null (set by finish).
      */
     downloadTileStart: function (context) {
-        const dataStore = context.userData,
-            image = new Image();
-
-        dataStore.image = image;
-        dataStore.request = null;
-
-        const finalize = function(error) {
-            if (error || !image) {
-                context.fail(error || "[downloadTileStart] Image load failed: undefined Image instance.",
-                    dataStore.request);
-                return;
-            }
-            image.onload = image.onerror = image.onabort = null;
-            context.finish(image, dataStore.request, "image");
-        };
-        image.onload = function () {
-            finalize();
-        };
-        image.onabort = image.onerror = function() {
-            finalize("[downloadTileStart] Image load aborted.");
-        };
-
         // Load the tile with an AJAX request if the loadWithAjax option is
         // set. Otherwise load the image by setting the source property of the image object.
         if (context.loadWithAjax) {
-            dataStore.request = $.makeAjaxRequest({
+            $.makeAjaxRequest({
                 url: context.src,
                 withCredentials: context.ajaxWithCredentials,
                 headers: context.ajaxHeaders,
@@ -859,22 +837,17 @@ $.TileSource.prototype = {
                     }
                     // If the blob is empty for some reason consider the image load a failure.
                     if (blb.size === 0) {
-                        finalize("[downloadTileStart] Empty image response.");
+                        context.fail("[downloadTileStart] Empty image response.", request);
                     } else {
-                        // Create a URL for the blob data and make it the source of the image object.
-                        // This will still trigger Image.onload to indicate a successful tile load.
-                        image.src = (window.URL || window.webkitURL).createObjectURL(blb);
+                        context.finish(blb, request, "rasterBlob");
                     }
                 },
                 error: function(request) {
-                    finalize("[downloadTileStart] Image load aborted - XHR error");
+                    context.fail("[downloadTileStart] Image load aborted - XHR error", request);
                 }
             });
         } else {
-            if (context.crossOriginPolicy !== false) {
-                image.crossOrigin = context.crossOriginPolicy;
-            }
-            image.src = context.src;
+            context.finish(context.src, null, "imageUrl");
         }
     },
 
@@ -1001,17 +974,17 @@ function processResponse( xhr ){
 
     if( responseText.match(/^\s*<.*/) ){
         try{
-        data = ( xhr.responseXML && xhr.responseXML.documentElement ) ?
-            xhr.responseXML :
-            $.parseXml( responseText );
+            data = ( xhr.responseXML && xhr.responseXML.documentElement ) ?
+                xhr.responseXML :
+                $.parseXml( responseText );
         } catch (e){
             data = xhr.responseText;
         }
     }else if( responseText.match(/\s*[{[].*/) ){
         try{
-          data = $.parseJSON(responseText);
+            data = $.parseJSON(responseText);
         } catch(e){
-          data =  responseText;
+            data =  responseText;
         }
     }else{
         data = responseText;
diff --git a/src/viewer.js b/src/viewer.js
index 82f9500fb..3b90c9e9b 100644
--- a/src/viewer.js
+++ b/src/viewer.js
@@ -910,7 +910,6 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype,
         this.world.removeAll();
         this.tileCache.clear();
         this.imageLoader.clear();
-
         /**
          * Raised when the viewer is closed (see {@link OpenSeadragon.Viewer#close}).
          *
@@ -1052,6 +1051,14 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype,
         this.removeAllHandlers();
     },
 
+    /**
+     * Check if the viewer has been destroyed or not yet initialized.
+     * @return {boolean}
+     */
+    isDestroyed() {
+        return !THIS[ this.hash ];
+    },
+
     /**
      * Request a drawer for this viewer, as a supported string or drawer constructor.
      * @param {String | OpenSeadragon.DrawerBase} drawerCandidate The type of drawer to try to construct.
@@ -1548,6 +1555,12 @@ $.extend( $.Viewer.prototype, $.EventSource.prototype, $.ControlDock.prototype,
             this.element.style.height = '100%';
 
             var onFullScreenChange = function() {
+                if (!THIS[ _this.hash ]) {
+                    $.removeEvent( document, $.fullScreenEventName, onFullScreenChange );
+                    $.removeEvent( document, $.fullScreenErrorEventName, onFullScreenChange );
+                    return;
+                }
+
                 var isFullScreen = $.isFullScreen();
                 if ( !isFullScreen ) {
                     $.removeEvent( document, $.fullScreenEventName, onFullScreenChange );
diff --git a/src/webgldrawer.js b/src/webgldrawer.js
index 0d4616217..691bdffa5 100644
--- a/src/webgldrawer.js
+++ b/src/webgldrawer.js
@@ -505,11 +505,6 @@
             context.restore();
         }
 
-        // private
-        _getTextureDataFromTile(tile){
-            return tile.getCanvasContext().canvas;
-        }
-
         /**
         * Draw data from the rendering canvas onto the output canvas, with clipping,
         * cropping and/or debug info as requested.
@@ -870,9 +865,14 @@
             let position;
 
             let data = cache.data;
+            let isCanvas = false;
+            if (data instanceof CanvasRenderingContext2D) {
+                data = data.canvas;
+                isCanvas = true;
+            }
 
             if (!tiledImage.isTainted()) {
-                if((data instanceof CanvasRenderingContext2D) && $.isCanvasTainted(data.canvas)){
+                if (isCanvas && $.isCanvasTainted(data)){
                     tiledImage.setTainted(true);
                     $.console.warn('WebGL cannot be used to draw this TiledImage because it has tainted data. Does crossOriginPolicy need to be set?');
                     this._raiseDrawerErrorEvent(tiledImage, 'Tainted data cannot be used by the WebGLDrawer. Falling back to CanvasDrawer for this TiledImage.');
@@ -981,14 +981,6 @@
             };
         }
 
-        // private
-//         _unloadTextures(){
-//             let canvases = Array.from(this._TextureMap.keys());
-//             canvases.forEach(canvas => {
-//                 this._cleanupImageData(canvas); // deletes texture, removes from _TextureMap
-//             });
-//         }
-
         _setClip(){
             // no-op: called by _renderToClippingCanvas when tiledImage._clip is truthy
             // so that tests will pass.
diff --git a/src/world.js b/src/world.js
index a4c0a17a3..122d01ecb 100644
--- a/src/world.js
+++ b/src/world.js
@@ -376,7 +376,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W
             return eventTarget.raiseEventAwaiting('tile-invalidated', {
                 tile: tile,
                 tiledImage: tiledImage,
-                outdated: () => originalCache.__invStamp !== tStamp || (!tile.loaded && !tile.loading),
+                outdated: () => this.viewer.isDestroyed() || originalCache.__invStamp !== tStamp || (!tile.loaded && !tile.loading),
                 getData: getWorkingCacheData,
                 setData: setWorkingCacheData,
                 resetData: () => {
@@ -386,6 +386,10 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W
                     }
                 }
             }).then(_ => {
+                if (this.viewer.isDestroyed()) {
+                    return null;
+                }
+
                 if (originalCache.__invStamp === tStamp && (tile.loaded || tile.loading)) {
                     if (workingCache) {
                         return workingCache.prepareForRendering(drawer).then(c => {
@@ -428,7 +432,10 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W
             for (let resolve of tileFinishResolvers) {
                 resolve();
             }
-            this.draw();
+
+            if (!this.viewer.isDestroyed()) {
+                this.draw();
+            }
         });
     },
 
diff --git a/test/modules/ajax-post-data.js b/test/modules/ajax-post-data.js
index fc6b072e1..90c2c7642 100644
--- a/test/modules/ajax-post-data.js
+++ b/test/modules/ajax-post-data.js
@@ -31,6 +31,12 @@
     var OriginalLoader = OpenSeadragon.ImageLoader;
     var OriginalAjax = OpenSeadragon.makeAjaxRequest;
 
+    var closeViewer = function() {
+        if (viewer && viewer.close) {
+            DONE ? viewer.destroy () : viewer.close();
+        }
+    }
+
     QUnit.module('AjaxPostData', {
         beforeEach: function () {
             testLog.reset();
@@ -159,14 +165,11 @@
 
         afterEach: function () {
             ASSERT = null;
-
-            if (viewer && viewer.close) {
-                DONE ? viewer.destroy () : viewer.close();
-            }
             viewer = null;
 
             OpenSeadragon.ImageLoader = OriginalLoader;
             OpenSeadragon.makeAjaxRequest = OriginalAjax;
+            OpenSeadragon.PostTestTileSource = null;
         }
     });
 
@@ -196,6 +199,7 @@
         var openHandler = function(event) {
             viewer.removeHandler('open', openHandler);
             openHandlerCalled = true;
+            closeViewer();
         };
 
         var readyHandler = function (event) {
diff --git a/test/modules/data-manipulation.js b/test/modules/data-manipulation.js
index fb4a4f5db..eb96e91f8 100644
--- a/test/modules/data-manipulation.js
+++ b/test/modules/data-manipulation.js
@@ -88,7 +88,9 @@
         // we test middle of the canvas, so that we can test both tiles or the output canvas of canvas drawer :)
         async function readTileData(tileRef = null) {
             // Get some time for viewer to load data
-            await sleep(50);
+            while (viewer.world.getItemAt(0).getTilesToDraw().length < 1) {
+                await sleep(10);
+            }
             // make sure at least one tile loaded
             const tile = tileRef || viewer.world.getItemAt(0).getTilesToDraw()[0];
             await tile[PROMISE_REF_KEY];
diff --git a/test/modules/tilecache.js b/test/modules/tilecache.js
index 483d3cacd..ccf4e91e5 100644
--- a/test/modules/tilecache.js
+++ b/test/modules/tilecache.js
@@ -1,7 +1,7 @@
 /* global QUnit, testLog */
 
 (function() {
-    const Convertor = OpenSeadragon.convertor,
+    const Converter = OpenSeadragon.converter,
         T_A = "__TEST__typeA", T_B = "__TEST__typeB", T_C = "__TEST__typeC", T_D = "__TEST__typeD", T_E = "__TEST__typeE";
 
     let viewer;
@@ -23,69 +23,78 @@
     // other tests will interfere
     let typeAtoB = 0, typeBtoC = 0, typeCtoA = 0, typeDtoA = 0, typeCtoE = 0;
     //set all same costs to get easy testing, know which path will be taken
-    Convertor.learn(T_A, T_B, (tile, x) => {
+    Converter.learn(T_A, T_B, (tile, x) => {
         typeAtoB++;
         return x+1;
     });
     // Costly conversion to C simulation
-    Convertor.learn(T_B, T_C, async (tile, x) => {
+    Converter.learn(T_B, T_C, async (tile, x) => {
         typeBtoC++;
         await sleep(5);
         return x+1;
     });
-    Convertor.learn(T_C, T_A, (tile, x) => {
+    Converter.learn(T_C, T_A, (tile, x) => {
         typeCtoA++;
         return x+1;
     });
-    Convertor.learn(T_D, T_A, (tile, x) => {
+    Converter.learn(T_D, T_A, (tile, x) => {
         typeDtoA++;
         return x+1;
     });
-    Convertor.learn(T_C, T_E, (tile, x) => {
+    Converter.learn(T_C, T_E, (tile, x) => {
         typeCtoE++;
         return x+1;
     });
     //'Copy constructors'
     let copyA = 0, copyB = 0, copyC = 0, copyD = 0, copyE = 0;
     //also learn destructors
-    Convertor.learn(T_A, T_A,(tile, x) => {
+    Converter.learn(T_A, T_A,(tile, x) => {
         copyA++;
         return x+1;
     });
-    Convertor.learn(T_B, T_B,(tile, x) => {
+    Converter.learn(T_B, T_B,(tile, x) => {
         copyB++;
         return x+1;
     });
-    Convertor.learn(T_C, T_C,(tile, x) => {
+    Converter.learn(T_C, T_C,(tile, x) => {
         copyC++;
         return x-1;
     });
-    Convertor.learn(T_D, T_D,(tile, x) => {
+    Converter.learn(T_D, T_D,(tile, x) => {
         copyD++;
         return x+1;
     });
-    Convertor.learn(T_E, T_E,(tile, x) => {
+    Converter.learn(T_E, T_E,(tile, x) => {
         copyE++;
         return x+1;
     });
     let destroyA = 0, destroyB = 0, destroyC = 0, destroyD = 0, destroyE = 0;
     //also learn destructors
-    Convertor.learnDestroy(T_A, () => {
+    Converter.learnDestroy(T_A, () => {
         destroyA++;
     });
-    Convertor.learnDestroy(T_B, () => {
+    Converter.learnDestroy(T_B, () => {
         destroyB++;
     });
-    Convertor.learnDestroy(T_C, () => {
+    Converter.learnDestroy(T_C, () => {
         destroyC++;
     });
-    Convertor.learnDestroy(T_D, () => {
+    Converter.learnDestroy(T_D, () => {
         destroyD++;
     });
-    Convertor.learnDestroy(T_E, () => {
+    Converter.learnDestroy(T_E, () => {
         destroyE++;
     });
 
+    const awaitDataGetForRendering = async function(cache, drawer, tile) {
+        let result = cache.getDataForRendering(drawer, tile);
+        while (!result) {
+            await sleep(10);
+            result = cache.getDataForRendering(drawer, tile);
+        }
+        return result;
+    }
+
     // ----------
     QUnit.module('TileCache', {
         beforeEach: function () {
@@ -201,7 +210,7 @@
                 internalCacheFree(data) {
                     super.internalCacheFree(data);
                     // Be nice and truly destroy the data copy
-                    OpenSeadragon.convertor.destroy(data, T_C);
+                    OpenSeadragon.converter.destroy(data, T_C);
                 }
             }
 
@@ -385,7 +394,9 @@
 
         viewer.addHandler('open', async () => {
             await viewer.waitForFinishedJobsForTest();
-            await sleep(1);  // necessary to make space for a draw call
+            while (tileCache._tilesLoaded.length < 1) {
+                await sleep(10);  // necessary to make space for a draw call
+            }
 
             test.ok(viewer.world.getItemAt(0).source instanceof OpenSeadragon.EmptyTestT_ATileSource, "Tests are done with empty test source type T_A.");
             test.ok(viewer.world.getItemAt(1).source instanceof OpenSeadragon.EmptyTestT_ATileSource, "Tests are done with empty test source type T_A.");
@@ -398,7 +409,8 @@
                 const cache = tile.getCache();
                 test.equal(cache.type, T_C, "Cache data was affected, the drawer supports only T_C since there is no way to get to T_E.");
 
-                const internalCache = cache.getDataForRendering(drawer, tile);
+                // We need to wait once the data is ready to be tested
+                const internalCache = await awaitDataGetForRendering(cache, drawer, tile);
                 test.equal(internalCache.type, viewer.drawer.getId(), "Sync conversion routine means T_C is also internal since dataCreate only creates data. However, internal cache keeps type of the drawer ID.");
                 test.ok(internalCache.loaded, "Internal cache ready.");
             }
@@ -489,7 +501,7 @@
                 test.equal(cache.type, T_A, "Main Cache Converted T_C -> T_A (drawer supports type A) (suite 1)");
                 test.equal(cache.data, 3, "Conversion step increases plugin-stored value 2 to 3");
 
-                const internalCache = cache.getDataForRendering(drawer, tile);
+                const internalCache = await awaitDataGetForRendering(cache, drawer, tile);
                 test.equal(internalCache.type, viewer.drawer.getId(), "Internal cache has type of the drawer ID.");
                 test.ok(internalCache.loaded, "Internal cache ready.");
             }
diff --git a/test/modules/tiledimage.js b/test/modules/tiledimage.js
index bbee01ce5..48f50031d 100644
--- a/test/modules/tiledimage.js
+++ b/test/modules/tiledimage.js
@@ -205,10 +205,13 @@
 
             viewer.addHandler('tiled-image-drawn', function updateHandler2() {
                 viewer.removeHandler('tiled-image-drawn', updateHandler2);
-                assert.ok(viewer.tileCache.numTilesLoaded() > 0, 'more tiles load');
-                viewer.world.getItemAt(0).destroy();
-                assert.equal(viewer.tileCache.numTilesLoaded(), 0, 'no tiles after destroy');
-                done();
+
+                setTimeout(() => {
+                    assert.ok(viewer.tileCache.numTilesLoaded() > 0, 'more tiles load');
+                    viewer.world.getItemAt(0).destroy();
+                    assert.equal(viewer.tileCache.numTilesLoaded(), 0, 'no tiles after destroy');
+                    done();
+                }, 20);
             });
         });
 
diff --git a/test/modules/type-conversion.js b/test/modules/type-conversion.js
index 4ca56cdcf..a522403cb 100644
--- a/test/modules/type-conversion.js
+++ b/test/modules/type-conversion.js
@@ -1,7 +1,7 @@
 /* global QUnit, $, Util, testLog */
 
 (function() {
-    const Convertor = OpenSeadragon.convertor;
+    const Converter = OpenSeadragon.converter;
 
     let viewer;
 
@@ -31,23 +31,23 @@
     let imageToCanvas = 0, srcToImage = 0, context2DtoImage = 0, canvasToContext2D = 0, imageToUrl = 0,
         canvasToUrl = 0;
     //set all same costs to get easy testing, know which path will be taken
-    Convertor.learn("__TEST__canvas", "__TEST__url", (tile, canvas) => {
+    Converter.learn("__TEST__canvas", "__TEST__url", (tile, canvas) => {
         canvasToUrl++;
         return canvas.toDataURL();
     }, 1, 1);
-    Convertor.learn("__TEST__image", "__TEST__url", (tile,image) => {
+    Converter.learn("__TEST__image", "__TEST__url", (tile,image) => {
         imageToUrl++;
         return image.url;
     }, 1, 1);
-    Convertor.learn("__TEST__canvas", "__TEST__context2d", (tile,canvas) => {
+    Converter.learn("__TEST__canvas", "__TEST__context2d", (tile,canvas) => {
         canvasToContext2D++;
         return canvas.getContext("2d");
     }, 1, 1);
-    Convertor.learn("__TEST__context2d", "__TEST__canvas", (tile,context2D) => {
+    Converter.learn("__TEST__context2d", "__TEST__canvas", (tile,context2D) => {
         context2DtoImage++;
         return context2D.canvas;
     }, 1, 1);
-    Convertor.learn("__TEST__image", "__TEST__canvas", (tile,image) => {
+    Converter.learn("__TEST__image", "__TEST__canvas", (tile,image) => {
         imageToCanvas++;
         const canvas = document.createElement( 'canvas' );
         canvas.width = image.width;
@@ -56,7 +56,7 @@
         context.drawImage( image, 0, 0 );
         return canvas;
     }, 1, 1);
-    Convertor.learn("__TEST__url", "__TEST__image", (tile, url) => {
+    Converter.learn("__TEST__url", "__TEST__image", (tile, url) => {
         return new Promise((resolve, reject) => {
             srcToImage++;
             const img = new Image();
@@ -68,17 +68,17 @@
 
     let canvasDestroy = 0, imageDestroy = 0, contex2DDestroy = 0, urlDestroy = 0;
     //also learn destructors
-    Convertor.learnDestroy("__TEST__canvas", canvas => {
+    Converter.learnDestroy("__TEST__canvas", canvas => {
         canvas.width = canvas.height = 0;
         canvasDestroy++;
     });
-    Convertor.learnDestroy("__TEST__image", () => {
+    Converter.learnDestroy("__TEST__image", () => {
         imageDestroy++;
     });
-    Convertor.learnDestroy("__TEST__context2d", () => {
+    Converter.learnDestroy("__TEST__context2d", () => {
         contex2DDestroy++;
     });
-    Convertor.learnDestroy("__TEST__url", () => {
+    Converter.learnDestroy("__TEST__url", () => {
         urlDestroy++;
     });
 
@@ -114,14 +114,14 @@
     QUnit.test('Conversion path deduction', function (test) {
         const done = test.async();
 
-        test.ok(Convertor.getConversionPath("__TEST__url", "__TEST__image"),
+        test.ok(Converter.getConversionPath("__TEST__url", "__TEST__image"),
             "Type conversion ok between TEST types.");
-        test.ok(Convertor.getConversionPath("url", "context2d"),
+        test.ok(Converter.getConversionPath("imageUrl", "context2d"),
             "Type conversion ok between real types.");
 
-        test.equal(Convertor.getConversionPath("url", "__TEST__image"), undefined,
+        test.equal(Converter.getConversionPath("imageUrl", "__TEST__image"), undefined,
             "Type conversion not possible between TEST and real types.");
-        test.equal(Convertor.getConversionPath("__TEST__canvas", "context2d"), undefined,
+        test.equal(Converter.getConversionPath("__TEST__canvas", "context2d"), undefined,
             "Type conversion not possible between TEST and real types.");
 
         done();
@@ -146,20 +146,20 @@
             context.drawImage( image, 0, 0 );
 
             //copy URL
-            const URL2 = await Convertor.copy(null, URL, "url");
+            const URL2 = await Converter.copy({}, URL, "imageUrl");
             //we cannot check if they are not the same object, strings are immutable (and we don't copy anyway :D )
             test.equal(URL, URL2, "String copy is equal in data.");
             test.equal(typeof URL, typeof URL2, "Type of copies equals.");
             test.equal(URL.length, URL2.length, "Data length is also equal.");
 
             //copy context
-            const context2 = await Convertor.copy(null, context, "context2d");
+            const context2 = await Converter.copy({}, context, "context2d");
             test.notEqual(context, context2, "Copy is not the same as original canvas.");
             test.equal(typeof context, typeof context2, "Type of copies equals.");
             test.equal(context.canvas.toDataURL(), context2.canvas.toDataURL(), "Data is equal.");
 
             //copy image
-            const image2 = await Convertor.copy(null, image, "image");
+            const image2 = await Converter.copy({}, image, "image");
             test.notEqual(image, image2, "Copy is not the same as original image.");
             test.equal(typeof image, typeof image2, "Type of copies equals.");
             test.equal(image.src, image2.src, "Data is equal.");
@@ -170,27 +170,27 @@
     });
 
     // ----------
-    QUnit.test('Manual Data Convertors: testing conversion, copies & destruction', function (test) {
+    QUnit.test('Manual Data Converters: testing conversion, copies & destruction', function (test) {
         const done = test.async();
 
         //load image object: url -> image
-        Convertor.convert(null, "/test/data/A.png", "__TEST__url", "__TEST__image").then(i => {
+        Converter.convert(null, "/test/data/A.png", "__TEST__url", "__TEST__image").then(i => {
             test.equal(OpenSeadragon.type(i), "image", "Got image object after conversion.");
             test.equal(srcToImage, 1, "Conversion happened.");
 
             test.equal(urlDestroy, 0, "Url destructor not called automatically.");
-            Convertor.destroy("/test/data/A.png", "__TEST__url");
+            Converter.destroy("/test/data/A.png", "__TEST__url");
             test.equal(urlDestroy, 1, "Url destructor called.");
 
             test.equal(imageDestroy, 0, "Image destructor not called.");
-            return Convertor.convert(null, i, "__TEST__image", "__TEST__canvas");
+            return Converter.convert({}, i, "__TEST__image", "__TEST__canvas");
         }).then(c => { //path image -> canvas
             test.equal(OpenSeadragon.type(c), "canvas", "Got canvas object after conversion.");
             test.equal(srcToImage, 1, "Conversion ulr->image did not happen.");
             test.equal(imageToCanvas, 1, "Conversion image->canvas happened.");
             test.equal(urlDestroy, 1, "Url destructor not called.");
             test.equal(imageDestroy, 0, "Image destructor not called unless we ask it.");
-            return Convertor.convert(null, c, "__TEST__canvas", "__TEST__image");
+            return Converter.convert({}, c, "__TEST__canvas", "__TEST__image");
         }).then(i => { //path canvas, image: canvas -> url -> image
             test.equal(OpenSeadragon.type(i), "image", "Got image object after conversion.");
             test.equal(srcToImage, 2, "Conversion ulr->image happened.");
@@ -208,7 +208,7 @@
         });
     });
 
-    QUnit.test('Data Convertors via Cache object: testing conversion & destruction', function (test) {
+    QUnit.test('Data Converters via Cache object: testing conversion & destruction', function (test) {
         const done = test.async();
         const dummyTile = MockSeadragon.getTile("", MockSeadragon.getTiledImage(), {cacheKey: "key"});
         const cache = MockSeadragon.getCacheRecord();
@@ -253,7 +253,7 @@
         });
     });
 
-    QUnit.test('Data Convertors via Cache object: testing set/get', function (test) {
+    QUnit.test('Data Converters via Cache object: testing set/get', function (test) {
         const done = test.async();
 
         const dummyTile = MockSeadragon.getTile("", MockSeadragon.getTiledImage(), {cacheKey: "key"});
@@ -309,7 +309,7 @@
         const done = test.async();
 
         let conversionHappened = false;
-        Convertor.learn("__TEST__url", "__TEST__longConversionProcessForTesting", (tile, value) => {
+        Converter.learn("__TEST__url", "__TEST__longConversionProcessForTesting", (tile, value) => {
             return new Promise((resolve, reject) => {
                 setTimeout(() => {
                     conversionHappened = true;
@@ -318,7 +318,7 @@
             });
         }, 1, 1);
         let longConversionDestroy = 0;
-        Convertor.learnDestroy("__TEST__longConversionProcessForTesting", _ => {
+        Converter.learnDestroy("__TEST__longConversionProcessForTesting", _ => {
             longConversionDestroy++;
         });
 
@@ -349,7 +349,7 @@
         const done = test.async();
 
         let conversionHappened = false;
-        Convertor.learn("__TEST__url", "__TEST__longConversionProcessForTesting", (tile, value) => {
+        Converter.learn("__TEST__url", "__TEST__longConversionProcessForTesting", (tile, value) => {
             return new Promise((resolve, reject) => {
                 setTimeout(() => {
                     conversionHappened = true;
@@ -358,7 +358,7 @@
             });
         }, 1, 1);
         let destructionHappened = false;
-        Convertor.learnDestroy("__TEST__longConversionProcessForTesting", _ => {
+        Converter.learnDestroy("__TEST__longConversionProcessForTesting", _ => {
             destructionHappened = true;
         });
 


Reviews:
user: login: Aiosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:28:02Z
user: login: Aiosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:29:35Z
user: login: Aiosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:30:12Z
user: login: Aiosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:31:16Z
user: login: Aiosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:32:19Z
user: login: Aiosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:40:26Z
user: login: Aiosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:41:58Z
user: login: Aiosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:42:19Z
user: login: Aiosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:53:11Z
user: login: Aiosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:58:15Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR migrates raster conversion functionality from background jobs to the conversion pipeline and updates several related test and source modules. Key changes include modifying test calls in type-conversion tests (passing an object instead of null), adding timing wrappers in tiled image tests, and introducing additional viewer and error‐handling checks in world, webgldrawer, tilesource, tiledimage, tilecache, and datatypeconvertor.

### Reviewed Changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated no comments.

<details>
<summary>Show a summary per file</summary>

| File                         | Description                                                                                   |
| ---------------------------- | --------------------------------------------------------------------------------------------- |
| test/modules/type-conversion.js  | Updated Convertor.copy calls to pass {} instead of null for copy operations.                 |
| test/modules/tiledimage.js       | Added a setTimeout wrapper when removing the tiled-image-drawn handler to delay assertions.  |
| test/modules/ajax-post-data.js   | Refactored viewer closure logic to remove duplicate viewer destruction calls.                |
| src/world.js                     | Added extra check for viewer destruction in tile invalidation and early return in draw logic.  |
| src/webgldrawer.js               | Removed unused private _getTextureDataFromTile helper and adjusted canvas data handling.      |
| src/viewer.js                    | Introduced a new isDestroyed method for robust destruction state checking.                    |
| src/tilesource.js               | Modified downloadTileStart to use context.finish with "url" type and adjusted ajax handling.    |
| src/tiledimage.js                | Wrapped best tile loading loop in a setTimeout and ensured safe return of empty arrays.        |
| src/tilecache.js                 | Added a catch call on promise chain to log errors during tile cache destruction.               |
| src/datatypeconvertor.js         | Added new conversion learning methods for "rasterBlob" to "image" and "context2d" conversions.  |
</details>



<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**test/modules/tiledimage.js:209**
* Using a fixed 20ms delay within the tiled-image-drawn handler may lead to intermittent failures under slower conditions. Consider replacing the fixed delay with a more robust synchronization mechanism, or increasing the delay as necessary.
```
setTimeout(() => {
```
**src/webgldrawer.js:505**
* The private method _getTextureDataFromTile was removed. Ensure that no external module or future code depends on it, or update such references to rely on alternative available methods for obtaining canvas data.
```
// private
_getTextureDataFromTile(tile){ ... }
```
**src/tilesource.js:850**
* Changing the image load branch to call context.finish with type 'url' instead of setting image.src should be verified. Ensure that downstream code handling this conversion correctly interprets the 'url' type for consistency with old behavior.
```
context.finish(context.src, null, "url");
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T16:50:12Z
user: login: iangilman
body: Seems sensible. Just a few notes…
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-02T17:34:14Z
user: login: Aiosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T08:48:53Z
user: login: Aiosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T08:50:06Z
user: login: iangilman
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T16:59:28Z
user: login: Aiosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T14:57:21Z
user: login: Aiosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T15:14:50Z
user: login: iangilman
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T16:41:43Z
user: login: iangilman
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T16:43:08Z
user: login: Aiosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T19:01:31Z
user: login: iangilman
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T16:50:36Z
user: login: iangilman
body: Awesome. Thank you for changing this over! 

The new doc comment looks great. And yeah, I think "imageUrl" is clearer than "url".
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T16:37:44Z
user: login: iangilman
body: Good catch! 

Looks like we're ready to merge now... agreed?
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-27T16:42:21Z

Review comments:
diff_hunk: @@ -202,6 +202,9 @@ $.DataTypeConvertor = class {
             const img = new Image();
             img.onerror = img.onabort = reject;
             img.onload = () => resolve(img);
+            if (tile.tiledImage && tile.tiledImage.crossOriginPolicy) {
path: src/datatypeconvertor.js
user: login: Aiosa
body: Attempt to respect this property, since it is now used to actually download the image data.
created_at: 2025-04-29T08:28:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -465,7 +465,7 @@
                     this._destroySelfUnsafe(this._data, this._type);
                 } else if (this._promise) {
                     const oldType = this._type;
-                    this._promise.then(x => this._destroySelfUnsafe(x, oldType));
+                    this._promise.then(x => this._destroySelfUnsafe(x, oldType)).catch($.console.error);
path: src/tilecache.js
user: login: Aiosa
body: Not sure why, but without catch the method in some tests threw error. After adding this part, the error vanished and no console error logs were written...
created_at: 2025-04-29T08:29:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1499,11 +1499,14 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
 
         // Load the new 'best' n tiles
         if (bestTiles && bestTiles.length > 0) {
-            for (let tile of bestTiles) {
-                if (tile) {
-                    this._loadTile(tile, currentTime);
+            // Avoid executing this in the frame loop
+            setTimeout(() => {
path: src/tiledimage.js
user: login: Aiosa
body: This makes for better framerates as requesting data through ajax is costly operation so avoid doing it on the animation frame.
created_at: 2025-04-29T08:30:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1771,7 +1774,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
 
         });
 
-        return tiles;
+        return tiles || [];
path: src/tiledimage.js
user: login: Aiosa
body: In tests this sometimes threw an error as it returned null, I was unable to isolate the cause, but still - if a method says it returns an array, it should not return null anyway.
created_at: 2025-04-29T08:31:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2223,6 +2226,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
         tile.tiledImage = this; //unloaded with tile.unload(), so we need to set it back
         // does nothing if tile.cacheKey already present
 
+        $.console.assert(dataType !== undefined, "TileSource::downloadTileStart must return a dataType.");
path: src/tiledimage.js
user: login: Aiosa
body: Even if I wanted to allow attempt to automatically deduct data type (if not provided), the usage of `addCache` below with callback requires the argument and fails otherwise, so it is safer to explicitly test.
created_at: 2025-04-29T08:32:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -806,32 +806,10 @@ $.TileSource.prototype = {
      * @param {string} [context.errorMsg] Private parameter. The final error message, default null (set by finish).
      */
     downloadTileStart: function (context) {
-        const dataStore = context.userData,
path: src/tilesource.js
user: login: Aiosa
body: This is the main change that triggered the rest. The idea is: why we force some format on the tile data here? Let's leave this up to the conversion pipeline to decide what is best for the drawer. This might not be obvious now, but for example WebGL can also work with blobs and avoid any conversion middle steps. We can add blob support to webgl later.

Moving more load to the conversion has also shown some issues with the async interaction, mainly in tests (e.g. the viewer gets destroyed and some routines are not yet finished).
created_at: 2025-04-29T08:40:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -870,9 +865,14 @@
             let position;
 
             let data = cache.data;
+            let canvas = false;
+            if (data instanceof CanvasRenderingContext2D) {
path: src/webgldrawer.js
user: login: Aiosa
body: This was a bug, context reference must be converted to the canvas one. Question is, how we discover that data is tainted if the data is not a canvas reference..? E.g. with a blob.
created_at: 2025-04-29T08:41:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -376,7 +376,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W
             return eventTarget.raiseEventAwaiting('tile-invalidated', {
                 tile: tile,
                 tiledImage: tiledImage,
-                outdated: () => originalCache.__invStamp !== tStamp || (!tile.loaded && !tile.loading),
+                outdated: () => this.viewer.isDestroyed() || originalCache.__invStamp !== tStamp || (!tile.loaded && !tile.loading),
path: src/world.js
user: login: Aiosa
body: This and few more places needed to also check that viewer is not destroyed.
created_at: 2025-04-29T08:42:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -205,10 +205,13 @@
 
             viewer.addHandler('tiled-image-drawn', function updateHandler2() {
                 viewer.removeHandler('tiled-image-drawn', updateHandler2);
-                assert.ok(viewer.tileCache.numTilesLoaded() > 0, 'more tiles load');
-                viewer.world.getItemAt(0).destroy();
-                assert.equal(viewer.tileCache.numTilesLoaded(), 0, 'no tiles after destroy');
-                done();
+
+                setTimeout(() => {
path: test/modules/tiledimage.js
user: login: Aiosa
body: One of examples how async changes the behavior. Sometimes, a frame was fired sooner than any tile got loaded (since now there is some job to do -> convert blob to image or url to image, and this takes more time and does not block the rendering - there was not immediatelly something loaded). Question is whether we want to tackle this somehow, but I would also say good that we make stuff more flexible and the frames are not blocked that much.
created_at: 2025-04-29T08:53:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -213,6 +216,28 @@ $.DataTypeConvertor = class {
             return context;
         };
 
+        this.learn("rasterBlob", "image", (tile, blob) => new $.Promise((resolve, reject) => {
path: src/datatypeconvertor.js
user: login: Aiosa
body: We can also talk about what are the best type names. `rasterBlob` or `blob` -> if we want to stress that the binary data is a raster image, or not. Or, we could get inspired by e.g. HTTP response and have pair data type + mime type... 
created_at: 2025-04-29T08:58:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -213,6 +216,28 @@ $.DataTypeConvertor = class {
             return context;
         };
 
+        this.learn("rasterBlob", "image", (tile, blob) => new $.Promise((resolve, reject) => {
path: src/datatypeconvertor.js
user: login: iangilman
body: rasterBlob sounds good to me. Nice to be a little specific. Maybe we'll have other kinds of bobs in the future?

Would be good to have this type documented somewhere.
created_at: 2025-05-02T17:24:42Z
author_association: MEMBER
diff_hunk: @@ -465,7 +465,7 @@
                     this._destroySelfUnsafe(this._data, this._type);
                 } else if (this._promise) {
                     const oldType = this._type;
-                    this._promise.then(x => this._destroySelfUnsafe(x, oldType));
+                    this._promise.then(x => this._destroySelfUnsafe(x, oldType)).catch($.console.error);
path: src/tilecache.js
user: login: iangilman
body: Odd.
created_at: 2025-05-02T17:27:47Z
author_association: MEMBER
diff_hunk: @@ -1499,11 +1499,14 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
 
         // Load the new 'best' n tiles
         if (bestTiles && bestTiles.length > 0) {
-            for (let tile of bestTiles) {
-                if (tile) {
-                    this._loadTile(tile, currentTime);
+            // Avoid executing this in the frame loop
+            setTimeout(() => {
path: src/tiledimage.js
user: login: iangilman
body: Nice 😊
created_at: 2025-05-02T17:28:28Z
author_association: MEMBER
diff_hunk: @@ -2223,6 +2226,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
         tile.tiledImage = this; //unloaded with tile.unload(), so we need to set it back
         // does nothing if tile.cacheKey already present
 
+        $.console.assert(dataType !== undefined, "TileSource::downloadTileStart must return a dataType.");
path: src/tiledimage.js
user: login: iangilman
body: This just logs an error… is there anything else we should do, like abort the action?
created_at: 2025-05-02T17:30:04Z
author_association: MEMBER
diff_hunk: @@ -870,9 +865,14 @@
             let position;
 
             let data = cache.data;
+            let canvas = false;
path: src/webgldrawer.js
user: login: iangilman
body: It would be better to call this `isCanvas` or such. Otherwise, just looking at the name, you would think it was a canvas, not a flag.
created_at: 2025-05-02T17:32:30Z
author_association: MEMBER
diff_hunk: @@ -376,7 +376,7 @@ $.extend( $.World.prototype, $.EventSource.prototype, /** @lends OpenSeadragon.W
             return eventTarget.raiseEventAwaiting('tile-invalidated', {
                 tile: tile,
                 tiledImage: tiledImage,
-                outdated: () => originalCache.__invStamp !== tStamp || (!tile.loaded && !tile.loading),
+                outdated: () => this.viewer.isDestroyed() || originalCache.__invStamp !== tStamp || (!tile.loaded && !tile.loading),
path: src/world.js
user: login: iangilman
body: Good to have this mechanism!
created_at: 2025-05-02T17:32:50Z
author_association: MEMBER
diff_hunk: @@ -205,10 +205,13 @@
 
             viewer.addHandler('tiled-image-drawn', function updateHandler2() {
                 viewer.removeHandler('tiled-image-drawn', updateHandler2);
-                assert.ok(viewer.tileCache.numTilesLoaded() > 0, 'more tiles load');
-                viewer.world.getItemAt(0).destroy();
-                assert.equal(viewer.tileCache.numTilesLoaded(), 0, 'no tiles after destroy');
-                done();
+
+                setTimeout(() => {
path: test/modules/tiledimage.js
user: login: iangilman
body: Agreed.

Do you find it has any effect on the perceived loading time?
created_at: 2025-05-02T17:33:44Z
author_association: MEMBER
diff_hunk: @@ -2223,6 +2226,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
         tile.tiledImage = this; //unloaded with tile.unload(), so we need to set it back
         // does nothing if tile.cacheKey already present
 
+        $.console.assert(dataType !== undefined, "TileSource::downloadTileStart must return a dataType.");
path: src/tiledimage.js
user: login: Aiosa
body: Not necessarily. The main idea was to give clear error cause, it would fail elsewhere with not-so-clear message. If someone implements custom data getter, they will get errors if they don't specify the format they return to the system. It's up to them to fix, I do not expect this to be issue elsehwere than in dev set-ups.
created_at: 2025-05-05T08:48:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -205,10 +205,13 @@
 
             viewer.addHandler('tiled-image-drawn', function updateHandler2() {
                 viewer.removeHandler('tiled-image-drawn', updateHandler2);
-                assert.ok(viewer.tileCache.numTilesLoaded() > 0, 'more tiles load');
-                viewer.world.getItemAt(0).destroy();
-                assert.equal(viewer.tileCache.numTilesLoaded(), 0, 'no tiles after destroy');
-                done();
+
+                setTimeout(() => {
path: test/modules/tiledimage.js
user: login: Aiosa
body: Will check more precisely to be sure.
created_at: 2025-05-05T08:50:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2223,6 +2226,8 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
         tile.tiledImage = this; //unloaded with tile.unload(), so we need to set it back
         // does nothing if tile.cacheKey already present
 
+        $.console.assert(dataType !== undefined, "TileSource::downloadTileStart must return a dataType.");
path: src/tiledimage.js
user: login: iangilman
body: Gotcha, thanks. 
created_at: 2025-05-06T16:59:28Z
author_association: MEMBER
diff_hunk: @@ -213,6 +216,28 @@ $.DataTypeConvertor = class {
             return context;
         };
 
+        this.learn("rasterBlob", "image", (tile, blob) => new $.Promise((resolve, reject) => {
path: src/datatypeconvertor.js
user: login: Aiosa
body: Yes, definitely. They are here. https://github.com/openseadragon/site-build/pull/289/files#diff-70a0f23b479a21812f4f1d805a5e03458940c0fd7786e0a73733c9b2d45577ea Maybe we could pull them out to some more visible place...
created_at: 2025-05-07T14:57:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -205,10 +205,13 @@
 
             viewer.addHandler('tiled-image-drawn', function updateHandler2() {
                 viewer.removeHandler('tiled-image-drawn', updateHandler2);
-                assert.ok(viewer.tileCache.numTilesLoaded() > 0, 'more tiles load');
-                viewer.world.getItemAt(0).destroy();
-                assert.equal(viewer.tileCache.numTilesLoaded(), 0, 'no tiles after destroy');
-                done();
+
+                setTimeout(() => {
path: test/modules/tiledimage.js
user: login: Aiosa
body: I tested this on the drawer performance with 20 overlays. I would say it behaves as expected. Although the main 'culprit' here is the extra `setTimeout`, not changes of where transfer to target data type happens. 

- Commented out  `setTimeout` / master branch: layers load individually, but a bit faster than in the second case. At the cost of stuttering animation, the viewer animation lags while it's loading 20 overlays.
- Active  `setTimeout`: layers load individually, they appear in bulks after some time. Animations are bit smoother.

But sometimes I could not really tell the difference. Looking at profiler, `setTimeout` generally makes frame handler bit faster...
created_at: 2025-05-07T15:14:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -213,6 +216,28 @@ $.DataTypeConvertor = class {
             return context;
         };
 
+        this.learn("rasterBlob", "image", (tile, blob) => new $.Promise((resolve, reject) => {
path: src/datatypeconvertor.js
user: login: iangilman
body: Would it make sense to have them somewhere in the doc comments?

I'm not seeing `rasterBlob` in that list. Should it be there?


created_at: 2025-05-08T16:41:43Z
author_association: MEMBER
diff_hunk: @@ -205,10 +205,13 @@
 
             viewer.addHandler('tiled-image-drawn', function updateHandler2() {
                 viewer.removeHandler('tiled-image-drawn', updateHandler2);
-                assert.ok(viewer.tileCache.numTilesLoaded() > 0, 'more tiles load');
-                viewer.world.getItemAt(0).destroy();
-                assert.equal(viewer.tileCache.numTilesLoaded(), 0, 'no tiles after destroy');
-                done();
+
+                setTimeout(() => {
path: test/modules/tiledimage.js
user: login: iangilman
body: Cool. Well, getting rid of stutter is definitely worth while!
created_at: 2025-05-08T16:43:08Z
author_association: MEMBER
diff_hunk: @@ -213,6 +216,28 @@ $.DataTypeConvertor = class {
             return context;
         };
 
+        this.learn("rasterBlob", "image", (tile, blob) => new $.Promise((resolve, reject) => {
path: src/datatypeconvertor.js
user: login: Aiosa
body: Yes it should, I just did not update it yet.
created_at: 2025-05-09T19:01:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -213,6 +216,28 @@ $.DataTypeConvertor = class {
             return context;
         };
 
+        this.learn("rasterBlob", "image", (tile, blob) => new $.Promise((resolve, reject) => {
path: src/datatypeconvertor.js
user: login: iangilman
body: Cool, don't forget to!
created_at: 2025-05-12T16:50:36Z
author_association: MEMBER

PR comments:
user: login: Aiosa
created_at: 2025-04-29T08:55:28Z
body: Hmm, tests are not being run again?
author_association: CONTRIBUTOR
user: login: iangilman
created_at: 2025-05-02T20:20:27Z
body: > Hmm, tests are not being run again?

I've now renewed our open source credits at Travis. You may have to push on your change for it to kick off a build.
author_association: MEMBER
user: login: iangilman
created_at: 2025-05-08T16:49:45Z
body: Looks like this patch is good except for possibly including the data types in doc comments? @Aiosa would you say it's ready to merge?

Now that the tests are running again, we're getting a build failure. It's fixed by https://github.com/openseadragon/openseadragon/pull/2722 so if you merge in the latest from master that should take care of it.
author_association: MEMBER
user: login: Aiosa
created_at: 2025-05-08T17:02:04Z
body: Thanks, I will look at the current master, although I thought it's something wrong with my code (though locally tests passed :D )
author_association: CONTRIBUTOR
user: login: Aiosa
created_at: 2025-05-09T15:03:49Z
body: Tests were failing occasionaly given the async execution... The above fixes the ones I've encountered.
author_association: CONTRIBUTOR
user: login: iangilman
created_at: 2025-05-09T16:41:16Z
body: Great, looking good!

I think this is ready to merge. @Aiosa do you agree?
author_association: MEMBER
user: login: Aiosa
created_at: 2025-05-09T19:03:23Z
body: > Looks like this patch is good except for possibly including the data types in doc comments?

I think it is not in code comments anywhere, how do you propose documenting it?
author_association: CONTRIBUTOR
user: login: iangilman
created_at: 2025-05-12T16:49:13Z
body: Maybe it should go in the doc comments for DataTypeConvertor?

As a side note, weren't we going to change that to DataTypeConverter?
author_association: MEMBER
user: login: Aiosa
created_at: 2025-05-13T17:09:59Z
body: I changed that everywhere because my experience is, it would make people mistake one for another :)
author_association: CONTRIBUTOR
user: login: Aiosa
created_at: 2025-05-13T17:34:52Z
body: Hmm I was reasoning that 'data url' could be interchangeable with urls... Will check if that's the cause, or whether it failed because of some other reason.
author_association: CONTRIBUTOR
user: login: Aiosa
created_at: 2025-05-27T08:35:33Z
body: Okay fixed some forgotten 'url' types that were left behind, looks ok.
author_association: CONTRIBUTOR
user: login: Aiosa
created_at: 2025-05-27T17:32:33Z
body: Yes
author_association: CONTRIBUTOR
user: login: iangilman
created_at: 2025-05-28T16:40:58Z
body: Okay, done!
author_association: MEMBER
user: login: iangilman
created_at: 2025-05-28T17:05:40Z
body: @Aiosa The build from this merge is failing: https://app.travis-ci.com/github/openseadragon/openseadragon/builds/275320513

I restarted it, in case it was bogus, but it failed again.

Oddly, the build from the changelog commit afterwards didn't fail: https://app.travis-ci.com/github/openseadragon/openseadragon/builds/275320533

Can you take a look? The failures definitely seem related to this patch.

author_association: MEMBER
user: login: Aiosa
created_at: 2025-05-28T17:09:49Z
body: Yes, I will have a look in 12 hours, now I am busy elsewhere :/
author_association: CONTRIBUTOR
user: login: Aiosa
created_at: 2025-05-29T10:03:09Z
body: Hmm it really looks like bogus - all of the tests that test cache behavior failed, which is not something this PR touched (at least not directly), and all of these tests are mine. Well, I will have a look hopefully it's just some typo again. Unfortunatelly I suspect there might be some uncertainity in play, as the async code might behave differently from run to run, at least that's the only explanation that comes to my mind since locally the tests were fine throughtought multiple runs. Well, I will try to check whether the tests themselves do not need to be more robust againts the async code..
author_association: CONTRIBUTOR
user: login: Aiosa
created_at: 2025-05-29T10:53:32Z
body: Continuing here https://github.com/openseadragon/openseadragon/pull/2738.
author_association: CONTRIBUTOR