From a7a2073d1a5c5d8f1a4ac7549a3a6b588a6d2e24 Mon Sep 17 00:00:00 2001 From: Oliver Wright Date: Fri, 8 Feb 2019 03:06:24 +0000 Subject: [PATCH] Add promise-based preload mechanism --- src/app.js | 1 + src/core/main.js | 2 + src/core/preload.js | 138 ++++++++++++++++++ test/unit/core/preload.js | 285 ++++++++++++++++++++++++++++++++++++++ test/unit/spec.js | 1 + 5 files changed, 427 insertions(+) create mode 100644 src/core/preload.js create mode 100644 test/unit/core/preload.js diff --git a/src/app.js b/src/app.js index 9e8ff19e35..b0b7429101 100644 --- a/src/app.js +++ b/src/app.js @@ -5,6 +5,7 @@ import './core/environment'; import './core/error_helpers'; import './core/helpers'; import './core/legacy'; +import './core/preload'; import './core/p5.Element'; import './core/p5.Graphics'; import './core/p5.Renderer'; diff --git a/src/core/main.js b/src/core/main.js index c7294b139b..963157671b 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -493,6 +493,8 @@ class p5 { f.call(this); } }, this); + // Set up promise preloads + this._setupPromisePreloads(); const friendlyBindGlobal = this._createFriendlyGlobalFunctionBinder(); diff --git a/src/core/preload.js b/src/core/preload.js new file mode 100644 index 0000000000..e8bf9dab9f --- /dev/null +++ b/src/core/preload.js @@ -0,0 +1,138 @@ +'use strict'; + +import p5 from './main'; + +p5.prototype._promisePreloads = [ + /* Example object + { + target: p5.prototype, // The target object to have the method modified + method: 'loadXAsync', // The name of the preload function to wrap + addCallbacks: true, // Whether to automatically handle the p5 callbacks + legacyPreloadSetup: { // Optional object to generate a legacy-style preload + method: 'loadX', // The name of the legacy preload function to generate + createBaseObject: function() { + return {}; + } // An optional function to create the base object for the legacy preload. + } + } + */ +]; + +p5.prototype.registerPromisePreload = function(setup) { + p5.prototype._promisePreloads.push(setup); +}; + +let initialSetupRan = false; + +p5.prototype._setupPromisePreloads = function() { + for (const preloadSetup of this._promisePreloads) { + let thisValue = this; + let { method, addCallbacks, legacyPreloadSetup } = preloadSetup; + // Get the target object that the preload gets assigned to by default, + // that is the current object. + let target = preloadSetup.target || this; + let sourceFunction = target[method].bind(target); + // If the target is the p5 prototype, then only set it up on the first run per page + if (target === p5.prototype) { + if (initialSetupRan) { + continue; + } + thisValue = null; + sourceFunction = target[method]; + } + + // Replace the original method with a wrapped version + target[method] = this._wrapPromisePreload( + thisValue, + sourceFunction, + addCallbacks + ); + // If a legacy preload is required + if (legacyPreloadSetup) { + // What is the name for this legacy preload + const legacyMethod = legacyPreloadSetup.method; + // Wrap the already wrapped Promise-returning method with the legacy setup + target[legacyMethod] = this._legacyPreloadGenerator( + thisValue, + legacyPreloadSetup, + target[method] + ); + } + } + initialSetupRan = true; +}; + +p5.prototype._wrapPromisePreload = function(thisValue, fn, addCallbacks) { + let replacementFunction = function(...args) { + // Uses the current preload counting mechanism for now. + this._incrementPreload(); + // A variable for the callback function if specified + let callback = null; + // A variable for the errorCallback function if specified + let errorCallback = null; + if (addCallbacks) { + // Loop from the end of the args array, pulling up to two functions off of + // the end and putting them in fns + for (let i = args.length - 1; i >= 0 && !errorCallback; i--) { + if (typeof args[i] !== 'function') { + break; + } + errorCallback = callback; + callback = args.pop(); + } + } + // Call the underlying funciton and pass it to Promise.resolve, + // so that even if it didn't return a promise we can still + // act on the result as if it did. + const promise = Promise.resolve(fn.apply(this, args)); + // Add the optional callbacks + if (callback) { + promise.then(callback); + } + if (errorCallback) { + promise.catch(errorCallback); + } + // Decrement the preload counter only if the promise resolved + promise.then(() => this._decrementPreload()); + // Return the original promise so that neither callback changes the result. + return promise; + }; + if (thisValue) { + replacementFunction = replacementFunction.bind(thisValue); + } + return replacementFunction; +}; + +const objectCreator = function() { + return {}; +}; + +p5.prototype._legacyPreloadGenerator = function( + thisValue, + legacyPreloadSetup, + fn +) { + // Create a function that will generate an object before the preload is + // launched. For example, if the object should be an array or be an instance + // of a specific class. + const baseValueGenerator = + legacyPreloadSetup.createBaseObject || objectCreator; + let returnedFunction = function() { + // Our then clause needs to run before setup, so we also increment the preload counter + this._incrementPreload(); + // Generate the return value based on the generator. + const returnValue = baseValueGenerator.apply(this, arguments); + // Run the original wrapper + fn.apply(this, arguments).then(data => { + // Copy each key from the resolved value into returnValue + Object.assign(returnValue, data); + // Decrement the preload counter, to allow setup to continue. + this._decrementPreload(); + }); + return returnValue; + }; + if (thisValue) { + returnedFunction = returnedFunction.bind(thisValue); + } + return returnedFunction; +}; diff --git a/test/unit/core/preload.js b/test/unit/core/preload.js new file mode 100644 index 0000000000..423b585283 --- /dev/null +++ b/test/unit/core/preload.js @@ -0,0 +1,285 @@ +suite('preloads', () => { + let preloadCache = null; + setup(() => { + preloadCache = p5.prototype._promisePreloads; + p5.prototype._promisePreloads = [...preloadCache]; + }); + + teardown(() => { + p5.prototype._promisePreloads = preloadCache; + }); + + suite('From external sources', () => { + test('Extension preload causes setup to wait', () => { + let resolved = false; + const target = { + async testPreloadFunction() { + await new Promise(res => setTimeout(res, 10)); + resolved = true; + } + }; + + p5.prototype._promisePreloads.push({ + target, + method: 'testPreloadFunction' + }); + + return promisedSketch((sketch, resolve, reject) => { + sketch.preload = () => { + target.testPreloadFunction(); + }; + + sketch.setup = () => { + if (resolved) { + resolve(); + } else { + reject(new Error('Sketch enetered setup too early.')); + } + }; + }); + }); + + test('Extension preload error causes setup to not execute', () => { + const target = { + async testPreloadFunction() { + throw new Error('Testing Error'); + } + }; + + p5.prototype._promisePreloads.push({ + target, + method: 'testPreloadFunction' + }); + + return promisedSketch((sketch, resolve, reject) => { + sketch.preload = () => { + target.testPreloadFunction(); + setTimeout(resolve, 10); + }; + + sketch.setup = () => { + reject('Sketch should not enter setup'); + }; + }); + }); + + suite('addCallbacks', () => { + test('Extension is passed all arguments when not using addCallbacks', () => { + const target = { + async testPreloadFunction(...args) { + assert.lengthOf(args, 3); + } + }; + + p5.prototype._promisePreloads.push({ + target, + method: 'testPreloadFunction', + addCallbacks: false + }); + + return promisedSketch((sketch, resolve, reject) => { + sketch.preload = () => { + target + .testPreloadFunction(() => {}, () => {}, () => {}) + .catch(reject); + }; + + sketch.setup = resolve; + }); + }); + + test('Extension gets stripped arguments when using addCallbacks', () => { + const target = { + async testPreloadFunction(...args) { + assert.lengthOf(args, 1); + } + }; + + p5.prototype._promisePreloads.push({ + target, + method: 'testPreloadFunction', + addCallbacks: true + }); + + return promisedSketch((sketch, resolve, reject) => { + sketch.preload = () => { + target + .testPreloadFunction(() => {}, () => {}, () => {}) + .catch(reject); + }; + + sketch.setup = resolve; + }); + }); + + test('Extension with addCallbacks supports success callback', () => { + const target = { + async testPreloadFunction(...args) { + assert.lengthOf(args, 1); + } + }; + + p5.prototype._promisePreloads.push({ + target, + method: 'testPreloadFunction', + addCallbacks: true + }); + + let success = 0; + + return promisedSketch((sketch, resolve, reject) => { + sketch.preload = () => { + target + .testPreloadFunction(0, () => { + success++; + }) + .catch(reject); + target + .testPreloadFunction( + () => {}, + () => { + success++; + }, + () => { + reject(new Error('Failure callback executed')); + } + ) + .catch(reject); + }; + + sketch.setup = () => { + if (success !== 2) { + reject( + new Error(`Not all success callbacks were run: ${success}/2`) + ); + } + resolve(); + }; + }); + }); + }); + + suite('legacyPreload', () => { + test('Extension legacy preload causes setup to wait', () => { + let resolved = false; + const target = { + async testPreloadFunction() { + await new Promise(res => setTimeout(res, 10)); + resolved = true; + } + }; + + p5.prototype._promisePreloads.push({ + target, + method: 'testPreloadFunction', + legacyPreloadSetup: { + method: 'testPreloadLegacy' + } + }); + + return promisedSketch((sketch, resolve, reject) => { + sketch.preload = () => { + target.testPreloadLegacy(); + }; + + sketch.setup = () => { + if (resolved) { + resolve(); + } else { + reject(new Error('Sketch enetered setup too early.')); + } + }; + }); + }); + + test('Extension legacy preload error causes setup to not execute', () => { + const target = { + async testPreloadFunction() { + throw new Error('Testing Error'); + } + }; + + p5.prototype._promisePreloads.push({ + target, + method: 'testPreloadFunction', + legacyPreloadSetup: { + method: 'testPreloadLegacy' + } + }); + + return promisedSketch((sketch, resolve, reject) => { + sketch.preload = () => { + target.testPreloadLegacy(); + setTimeout(resolve, 10); + }; + + sketch.setup = () => { + reject('Sketch should not enter setup'); + }; + }); + }); + + test('Extension legacy preload returns objects correctly', async () => { + let testItem = { + test: true, + otherTest: [] + }; + const target = { + async testPreloadFunction() { + return testItem; + } + }; + + p5.prototype._promisePreloads.push({ + target, + method: 'testPreloadFunction', + legacyPreloadSetup: { + method: 'testPreloadLegacy' + } + }); + + let testResult; + + await promisedSketch((sketch, resolve, reject) => { + sketch.preload = () => { + testResult = target.testPreloadLegacy(); + }; + + sketch.setup = resolve(); + }); + + assert.deepEqual(testResult, testItem); + }); + + test('Extension legacy preload returns arrays correctly', async () => { + let testItem = [true, [], {}]; + const target = { + async testPreloadFunction() { + return testItem; + } + }; + + p5.prototype._promisePreloads.push({ + target, + method: 'testPreloadFunction', + legacyPreloadSetup: { + method: 'testPreloadLegacy', + createBaseObject: () => [] + } + }); + + let testResult; + + await promisedSketch((sketch, resolve, reject) => { + sketch.preload = () => { + testResult = target.testPreloadLegacy(); + }; + + sketch.setup = resolve(); + }); + + assert.deepEqual(testResult, testItem); + }); + }); + }); +}); diff --git a/test/unit/spec.js b/test/unit/spec.js index 619d8a4e93..3fce86774c 100644 --- a/test/unit/spec.js +++ b/test/unit/spec.js @@ -10,6 +10,7 @@ var spec = { 'main', 'p5.Element', 'p5.Graphics', + 'preload', 'rendering', 'structure', 'transform',