From 281c7e79ecdc1c3aaa9b5e3e27632d72355e279a Mon Sep 17 00:00:00 2001 From: Johnny Huynh Date: Sat, 29 Aug 2026 20:32:42 -0700 Subject: [PATCH 01/15] loading.js uses canvas-based animation and each sketch instance will have its own loading indicator centered on the canvas --- src/core/loading.js | 217 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 172 insertions(+), 45 deletions(-) diff --git a/src/core/loading.js b/src/core/loading.js index b803d48340..2f502e18f9 100644 --- a/src/core/loading.js +++ b/src/core/loading.js @@ -4,74 +4,201 @@ * @private * * Handles the logic for creating a loading indicator. - * Currently, the loading indicator is basic and can be extended in the future. */ /** * Creates a loading indicator when the sketch's setup() function is running. - * It is called and removed automatically using the presetup and postsetup lifecycles hooks. + * It is called and removed automatically using the presetup and postsetup lifecycle hooks. + * Registers loading indicator decorators for createCanvas(), resizeCanvas(), and noCanvas() + * to manage the loading indicator overlay. * * @param {*} p5 The p5 constructor * @param {*} fn The p5 prototype object * @param {*} lifecycles Lifecycle hooks for the sketch */ export default function loading(p5, fn, lifecycles) { + p5.registerDecorator('p5.prototype.createCanvas', _handleLoadingIndicator(true)); + p5.registerDecorator('p5.prototype.resizeCanvas', _handleLoadingIndicator(true)); + p5.registerDecorator('p5.prototype.noCanvas', _handleLoadingIndicator(false)); + lifecycles.presetup = function () { - if (typeof window === 'undefined' || this._loadingIndicator) { + if (typeof window === 'undefined') { return; } - - const canvasParent = this.canvas?.parentElement; - let container = this._userNode || canvasParent || document.body; - - if (typeof container === 'string') { - container = document.getElementById(container) || document.body; - } - - this._loadingIndicator = createLoadingIndicator(container); + this._isSketchLoading = true; }; lifecycles.postsetup = function () { - if (this._loadingIndicator) { - this._loadingIndicator.remove(); - this._loadingIndicator = null; - } + this._isSketchLoading = false; + _removeLoadingOverlay(this); }; } /** - * Creates and stylizes the loading indicator. - * As a helper function, it can be extensible and modified in future versions. + * Creates the loading canvas to directly overlay the sketch canvas + * and starts the spinning logo animation loop. + * + * @private + * @param {p5} pInst The p5 instance. + */ +function _createLoadingOverlay(pInst) { + const actualCanvas = pInst.canvas?.elt || pInst.canvas; + if (!actualCanvas) return; + + let overlay = pInst._loadingOverlay; + + // If overlay doesn't exist yet, create it and animate it + if (!overlay) { + overlay = document.createElement('canvas'); + overlay.id = `${actualCanvas.id || 'defaultCanvas0'}_loadingOverlay`; + pInst._loadingOverlay = overlay; + + const ctx = overlay.getContext('2d'); + let frameCount = 0; + + const animate = () => { + if (!pInst._isSketchLoading) return; + + ctx.clearRect(0, 0, overlay.width, overlay.height); + _drawLoadingIndicator( + ctx, + overlay.width / 2, + overlay.height / 2, + frameCount++ + ); + + pInst._loadingOverlayFrame = requestAnimationFrame(animate); + }; + + animate(); + } + + // Positions the loading indicator to overlay the sketch canvas + _positionCanvas(overlay, actualCanvas); + + if (overlay.parentNode !== actualCanvas.parentNode) { + actualCanvas.parentNode.insertBefore(overlay, actualCanvas.nextSibling); + } +} + +/** + * Matches the size and position of the loading canvas to the user's sketch canvas. + * + * @private + * @param {HTMLCanvasElement} loadingCanvas The overlay canvas element. + * @param {HTMLCanvasElement} actualCanvas The sketch canvas element. + */ +function _positionCanvas(loadingCanvas, actualCanvas) { + loadingCanvas.width = actualCanvas.width; + loadingCanvas.height = actualCanvas.height; + + const width = actualCanvas.style.width || `${actualCanvas.offsetWidth || actualCanvas.width}px`; + const height = actualCanvas.style.height || `${actualCanvas.offsetHeight || actualCanvas.height}px`; + + Object.assign(loadingCanvas.style, { + width, + height, + position: 'absolute', + top: `${actualCanvas.offsetTop}px`, + left: `${actualCanvas.offsetLeft}px`, + margin: '0', + padding: '0', + pointerEvents: 'none', + zIndex: '9999' + }); +} + +/** + * Stops the loading indicator animation and removes the overlay canvas from the DOM. + * + * Cancels the requestAnimationFrame loop and removes the overlay canvas + * element from the document. * * @private - * @param {HTMLElement} container The HTML element to append the indicator to - * @returns {HTMLElement} The loading indicator div element + * @param {p5} pInst The p5 instance. */ -function createLoadingIndicator(container) { - if (!document.getElementById('p5-loading-style')) { - const loadingStyle = document.createElement('style'); - loadingStyle.id = 'p5-loading-style'; - loadingStyle.textContent = - '@keyframes p5-loading-spin { to { transform: rotate(360deg); } }'; - document.head.appendChild(loadingStyle); +function _removeLoadingOverlay(pInst) { + if (pInst._loadingOverlay) { + cancelAnimationFrame(pInst._loadingOverlayFrame); + pInst._loadingOverlay.remove(); + pInst._loadingOverlay = null; } +} + +/** + * Draws a canvas-based animated loading indicator. + * The loading indcator is a spinning p5 logo. + * + * Credits to Raphaël de Courville for creating the p5 logo sketch + * + * @private + * @param {CanvasRenderingContext2D} ctx The 2D canvas context to draw on. + * @param {Number} x The x-coordinate for the logo center. + * @param {Number} y The y-coordinate for the logo center. + * @param {Number} t The frame count used to calculate rotation. + */ +function _drawLoadingIndicator(ctx, x, y, t) { + let rotationSpeed = 3.25; + let indicatorSize = 1.5; - const indicator = document.createElement('div'); - indicator.className = 'loading-indicator'; - indicator.style.cssText = ` - position: fixed; - inset: 0; - margin: auto; - width: 30px; - height: 30px; - border-radius: 50%; - - border: 3px solid rgba(0, 0, 0, 0.1); - border-top-color: rgba(0, 0, 0, 0.8); - animation: p5-loading-spin 1s linear infinite; - z-index: 9999; - `; - - container.appendChild(indicator); - return indicator; + ctx.save(); + ctx.translate(x, y); + ctx.scale(indicatorSize, indicatorSize); + + ctx.rotate((t * rotationSpeed * Math.PI) / 180); + ctx.translate(-14, -14); + + ctx.fillStyle = '#ED225D'; + ctx.beginPath(); + + ctx.moveTo(16.909, 10.259); + ctx.lineTo(25.442, 7.683); + ctx.lineTo(27.118, 12.839); + ctx.lineTo(18.62, 15.738); + ctx.lineTo(23.895, 23.218); + ctx.lineTo(19.448, 26.443); + ctx.lineTo(13.895, 19.095); + ctx.lineTo(8.487, 26.25); + ctx.lineTo(4.169, 22.961); + ctx.lineTo(9.444, 15.738); + ctx.lineTo(0.88, 12.647); + ctx.lineTo(2.558, 7.487); + ctx.lineTo(11.156, 10.258); + ctx.lineTo(11.156, 1.364); + ctx.lineTo(16.91, 1.364); + + ctx.closePath(); + ctx.fill(); + ctx.restore(); } + +/** + * Intercepts canvas methods to create, update, or remove the loading indicator + * + * @private + * @internal + * + * @param {Boolean} isLoading True to show the loading indicator; false to hide it. + * @return {Function} A decorator function for the target canvas method. + */ +export function _handleLoadingIndicator(isLoading) { + return function (target) { + return function (...args) { + const result = target.call(this, ...args); + + // Create loading sketch if canvas is loading + if (isLoading) { + if (this._isSketchLoading) { + _createLoadingOverlay(this); + } + } + + // Remove loading sketch if canvas isn't loading + else { + _removeLoadingOverlay(this); + } + + return result; + }; + }; +} \ No newline at end of file From 1c46bb162504270577f7adbef30e617f0f55c9e1 Mon Sep 17 00:00:00 2001 From: Johnny Huynh Date: Sat, 29 Aug 2026 20:41:27 -0700 Subject: [PATCH 02/15] Small descriptions and grammar fixes --- src/core/loading.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/core/loading.js b/src/core/loading.js index 2f502e18f9..3d15f0b569 100644 --- a/src/core/loading.js +++ b/src/core/loading.js @@ -109,10 +109,7 @@ function _positionCanvas(loadingCanvas, actualCanvas) { } /** - * Stops the loading indicator animation and removes the overlay canvas from the DOM. - * - * Cancels the requestAnimationFrame loop and removes the overlay canvas - * element from the document. + * Stops the loading indicator animation and removes the overlay canvas from the DO * * @private * @param {p5} pInst The p5 instance. @@ -127,7 +124,7 @@ function _removeLoadingOverlay(pInst) { /** * Draws a canvas-based animated loading indicator. - * The loading indcator is a spinning p5 logo. + * The loading indicator is a spinning p5 logo. * * Credits to Raphaël de Courville for creating the p5 logo sketch * @@ -186,14 +183,14 @@ export function _handleLoadingIndicator(isLoading) { return function (...args) { const result = target.call(this, ...args); - // Create loading sketch if canvas is loading + // Create loading overlay if canvas is loading if (isLoading) { if (this._isSketchLoading) { _createLoadingOverlay(this); } } - // Remove loading sketch if canvas isn't loading + // Remove loading overlay if canvas isn't loading else { _removeLoadingOverlay(this); } From b3a4f09d323e43e8f253e7df11bc7d1e9a3440c0 Mon Sep 17 00:00:00 2001 From: Johnny Huynh Date: Mon, 31 Aug 2026 22:07:07 -0700 Subject: [PATCH 03/15] Updated loading.js tests to use mockP5 and mockP5Prototype --- src/core/loading.js | 1 + test/unit/core/loading.js | 70 ++++++++++++++++++++++++++++++++++----- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/core/loading.js b/src/core/loading.js index 3d15f0b569..f3ca6968a7 100644 --- a/src/core/loading.js +++ b/src/core/loading.js @@ -51,6 +51,7 @@ function _createLoadingOverlay(pInst) { if (!overlay) { overlay = document.createElement('canvas'); overlay.id = `${actualCanvas.id || 'defaultCanvas0'}_loadingOverlay`; + overlay.classList.add('loading-indicator'); pInst._loadingOverlay = overlay; const ctx = overlay.getContext('2d'); diff --git a/test/unit/core/loading.js b/test/unit/core/loading.js index 53659175ee..bacf73fb14 100644 --- a/test/unit/core/loading.js +++ b/test/unit/core/loading.js @@ -1,12 +1,15 @@ -import { vi, suite, test, assert } from 'vitest'; +import { vi, suite, test, assert, beforeAll } from 'vitest'; import loading from '../../../src/core/loading.js'; +import { mockP5, mockP5Prototype } from '../../js/mocks'; suite('Loading indicator', function () { let container; let canvas; - const lifecycles = {}; - loading(null, null, lifecycles); + + beforeAll(function () { + loading(mockP5, mockP5Prototype, lifecycles); + }); beforeEach(function () { container = document.createElement('div'); @@ -35,7 +38,8 @@ suite('Loading indicator', function () { width: 400, height: 400, mouseX: 12, - mouseY: 34 + mouseY: 34, + _isSketchLoading: false }; const load = async delay => { @@ -49,6 +53,13 @@ suite('Loading indicator', function () { try { p.createCanvas(400, 400); + + if (p._isSketchLoading && !p._loadingOverlay) { + const overlay = document.createElement('canvas'); + overlay.classList.add('loading-indicator'); + container.appendChild(overlay); + p._loadingOverlay = overlay; + } await load(2000); @@ -80,15 +91,30 @@ suite('Loading indicator', function () { }); test('test the loading indicator in an instance', function () { + const canvas = document.createElement('canvas'); + container.appendChild(canvas); + const p = { - _userNode: container + canvas: canvas, + _userNode: container, + _isSketchLoading: false }; lifecycles.presetup.call(p); - assert.exists(container.querySelector('.loading-indicator')); + assert.equal(p._isSketchLoading, true, '_isSketchLoading should be true after presetup'); + + if (p._isSketchLoading) { + const overlay = document.createElement('canvas'); + overlay.classList.add('loading-indicator'); + overlay.id = 'testLoadingOverlay'; + container.appendChild(overlay); + p._loadingOverlay = overlay; + } + + assert.exists(container.querySelector('.loading-indicator'), 'Loading indicator should exist'); lifecycles.postsetup.call(p); - assert.isNull(container.querySelector('.loading-indicator')); + assert.isNull(container.querySelector('.loading-indicator'), 'Loading indicator should be removed'); }); test('test multiple indicators for multiple instances', async function () { @@ -97,6 +123,11 @@ suite('Loading indicator', function () { document.body.appendChild(instance1); document.body.appendChild(instance2); + const canvas1 = document.createElement('canvas'); + const canvas2 = document.createElement('canvas'); + instance1.appendChild(canvas1); + instance2.appendChild(canvas2); + let resolveLoad1; let resolveLoad2; @@ -112,11 +143,26 @@ suite('Loading indicator', function () { }); }; - const p1 = { _userNode: instance1 }; - const p2 = { _userNode: instance2 }; + const p1 = { + _userNode: instance1, + canvas: canvas1, + _isSketchLoading: false + }; + const p2 = { + _userNode: instance2, + canvas: canvas2, + _isSketchLoading: false + }; const setup1 = (async function () { lifecycles.presetup.call(p1); + // Simulate the decorator creating the overlay + if (p1._isSketchLoading) { + const overlay1 = document.createElement('canvas'); + overlay1.classList.add('loading-indicator'); + instance1.appendChild(overlay1); + p1._loadingOverlay = overlay1; + } try { await load1(2000); } finally { @@ -126,6 +172,12 @@ suite('Loading indicator', function () { const setup2 = (async function () { lifecycles.presetup.call(p2); + if (p2._isSketchLoading) { + const overlay2 = document.createElement('canvas'); + overlay2.classList.add('loading-indicator'); + instance2.appendChild(overlay2); + p2._loadingOverlay = overlay2; + } try { await load2(4000); } finally { From 9752dcc9352c599ef874766d41de621297b38426 Mon Sep 17 00:00:00 2001 From: Johnny Huynh Date: Thu, 3 Sep 2026 21:59:33 -0700 Subject: [PATCH 04/15] "noLoadingIndicator()" function added --- src/core/loading.js | 22 ++++++++++++++++++---- src/core/main.js | 4 +++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/core/loading.js b/src/core/loading.js index f3ca6968a7..d73c88dd12 100644 --- a/src/core/loading.js +++ b/src/core/loading.js @@ -21,10 +21,20 @@ export default function loading(p5, fn, lifecycles) { p5.registerDecorator('p5.prototype.resizeCanvas', _handleLoadingIndicator(true)); p5.registerDecorator('p5.prototype.noCanvas', _handleLoadingIndicator(false)); + /** + * Disables the loading indicator. + * Calling `noLoadingIndicator()` will disable the loading + * indicator the following lines of code of the current sketch + * + * @method noLoadingIndicator + */ + fn.noLoadingIndicator = function () { + this._loadingIndicatorDisabled = true; + _removeLoadingOverlay(this); + return this; + }; + lifecycles.presetup = function () { - if (typeof window === 'undefined') { - return; - } this._isSketchLoading = true; }; @@ -42,6 +52,10 @@ export default function loading(p5, fn, lifecycles) { * @param {p5} pInst The p5 instance. */ function _createLoadingOverlay(pInst) { + if (pInst._loadingIndicatorDisabled) { + return; + } + const actualCanvas = pInst.canvas?.elt || pInst.canvas; if (!actualCanvas) return; @@ -186,7 +200,7 @@ export function _handleLoadingIndicator(isLoading) { // Create loading overlay if canvas is loading if (isLoading) { - if (this._isSketchLoading) { + if (this._isSketchLoading && !this._loadingIndicatorDisabled) { _createLoadingOverlay(this); } } diff --git a/src/core/main.js b/src/core/main.js index 31126e659b..4284504e0c 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -654,7 +654,9 @@ p5.registerAddon(rendering); p5.registerAddon(renderer); p5.registerAddon(renderer2D); p5.registerAddon(graphics); -p5.registerAddon(loading); +if (typeof window !== 'undefined') { + p5.registerAddon(loading); +} export default p5; From 02c56b64d39ddb37b1c787a8729ceeff21c68b9c Mon Sep 17 00:00:00 2001 From: Johnny Huynh Date: Thu, 3 Sep 2026 22:15:19 -0700 Subject: [PATCH 05/15] Added test for "noLoadingIndicator()" --- test/unit/core/loading.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/unit/core/loading.js b/test/unit/core/loading.js index bacf73fb14..c8f3483d25 100644 --- a/test/unit/core/loading.js +++ b/test/unit/core/loading.js @@ -117,6 +117,23 @@ suite('Loading indicator', function () { assert.isNull(container.querySelector('.loading-indicator'), 'Loading indicator should be removed'); }); + test('disables the loading indicator', function () { + const p = Object.assign({}, mockP5Prototype, { + canvas, + _loadingIndicatorDisabled: false + }); + const overlay = document.createElement('canvas'); + overlay.classList.add('loading-indicator'); + container.appendChild(overlay); + p._loadingOverlay = overlay; + + const result = p.noLoadingIndicator(); + + assert.isTrue(p._loadingIndicatorDisabled); + assert.isNull(container.querySelector('.loading-indicator')); + assert.strictEqual(result, p); + }); + test('test multiple indicators for multiple instances', async function () { const instance1 = document.createElement('div'); const instance2 = document.createElement('div'); From 338dced5669f0940a764d10f57b69752923dd136 Mon Sep 17 00:00:00 2001 From: Johnny Huynh Date: Fri, 11 Sep 2026 19:29:42 -0700 Subject: [PATCH 06/15] custom loading indicator (first implementation) --- src/core/loading.js | 55 +++++++++++++++++++++++++++------------ test/unit/core/loading.js | 23 +++++++++++++--- 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/core/loading.js b/src/core/loading.js index d73c88dd12..a67e7e74f6 100644 --- a/src/core/loading.js +++ b/src/core/loading.js @@ -22,14 +22,21 @@ export default function loading(p5, fn, lifecycles) { p5.registerDecorator('p5.prototype.noCanvas', _handleLoadingIndicator(false)); /** - * Disables the loading indicator. - * Calling `noLoadingIndicator()` will disable the loading - * indicator the following lines of code of the current sketch + * Sets a custom loading animation for the current sketch. * - * @method noLoadingIndicator + * Calling `loadingAnimation()` without a callback disables the loading + * animation. Calling it with a callback replaces the default animation. + * The callback receives the overlay's 2D rendering context, its width, + * its height, and the current animation frame. The callback's `this` value + * is the current p5 instance. + * + * @method loadingAnimation + * @param {Function} [callback] Function used to draw each animation frame. + * @chainable */ - fn.noLoadingIndicator = function () { - this._loadingIndicatorDisabled = true; + fn.loadingAnimation = function (callback) { + this._hasCustomLoading = true; + this._loadingAnimation = callback; _removeLoadingOverlay(this); return this; }; @@ -52,7 +59,7 @@ export default function loading(p5, fn, lifecycles) { * @param {p5} pInst The p5 instance. */ function _createLoadingOverlay(pInst) { - if (pInst._loadingIndicatorDisabled) { + if (pInst._hasCustomLoading && typeof pInst._loadingAnimation !== 'function') { return; } @@ -72,15 +79,29 @@ function _createLoadingOverlay(pInst) { let frameCount = 0; const animate = () => { - if (!pInst._isSketchLoading) return; - + if (!pInst._isSketchLoading) { + return; + } + ctx.clearRect(0, 0, overlay.width, overlay.height); - _drawLoadingIndicator( - ctx, - overlay.width / 2, - overlay.height / 2, - frameCount++ - ); + const frame = frameCount++; + if (pInst._hasCustomLoading) { + pInst._loadingAnimation.call( + pInst, + ctx, + overlay.width, + overlay.height, + frame + ); + } + else { + _drawLoadingIndicator( + ctx, + overlay.width / 2, + overlay.height / 2, + frame + ); + } pInst._loadingOverlayFrame = requestAnimationFrame(animate); }; @@ -200,10 +221,10 @@ export function _handleLoadingIndicator(isLoading) { // Create loading overlay if canvas is loading if (isLoading) { - if (this._isSketchLoading && !this._loadingIndicatorDisabled) { + if (this._isSketchLoading) { _createLoadingOverlay(this); } - } + } // Remove loading overlay if canvas isn't loading else { diff --git a/test/unit/core/loading.js b/test/unit/core/loading.js index c8f3483d25..5fbcfe34cd 100644 --- a/test/unit/core/loading.js +++ b/test/unit/core/loading.js @@ -117,23 +117,38 @@ suite('Loading indicator', function () { assert.isNull(container.querySelector('.loading-indicator'), 'Loading indicator should be removed'); }); - test('disables the loading indicator', function () { + test('disables the loading animation without a callback', function () { const p = Object.assign({}, mockP5Prototype, { canvas, - _loadingIndicatorDisabled: false + _hasCustomLoading: false }); const overlay = document.createElement('canvas'); overlay.classList.add('loading-indicator'); container.appendChild(overlay); p._loadingOverlay = overlay; - const result = p.noLoadingIndicator(); + const result = p.loadingAnimation(); - assert.isTrue(p._loadingIndicatorDisabled); + assert.isTrue(p._hasCustomLoading); + assert.isUndefined(p._loadingAnimation); assert.isNull(container.querySelector('.loading-indicator')); assert.strictEqual(result, p); }); + test('uses a custom loading animation', function () { + const callback = vi.fn(); + const p = Object.assign({}, mockP5Prototype, { + canvas, + _isSketchLoading: true + }); + + p.loadingAnimation(callback); + + assert.isTrue(p._hasCustomLoading); + assert.strictEqual(p._loadingAnimation, callback); + assert.strictEqual(p.loadingAnimation(callback), p); + }); + test('test multiple indicators for multiple instances', async function () { const instance1 = document.createElement('div'); const instance2 = document.createElement('div'); From aeded1510e89a46f1970d50384ae7ca95824c2f8 Mon Sep 17 00:00:00 2001 From: Johnny Huynh Date: Fri, 11 Sep 2026 20:25:12 -0700 Subject: [PATCH 07/15] gets rid of loading indicator if promises are unresolved --- src/core/main.js | 10 +++++++-- test/unit/core/loading.js | 46 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/core/main.js b/src/core/main.js index 0d3fcb456a..8342687de9 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -242,8 +242,14 @@ class p5 { this._millisStart = globalThis.performance.now(); const context = this._isGlobal ? window : this; - if (typeof context.setup === 'function') { - await context.setup(); + try { + if (typeof context.setup === 'function') { + await context.setup(); + } + } + catch (error) { + await this._runLifecycleHook('postsetup'); + throw error; } if (this.hitCriticalError) return; diff --git a/test/unit/core/loading.js b/test/unit/core/loading.js index 5fbcfe34cd..8b825522a1 100644 --- a/test/unit/core/loading.js +++ b/test/unit/core/loading.js @@ -68,7 +68,8 @@ suite('Loading indicator', function () { p.circle(p.width / 2, p.height / 2, 100); p.circle(p.mouseX, p.mouseY, 20); - } finally { + } + finally { lifecycles.postsetup.call(p); } })(); @@ -90,6 +91,42 @@ suite('Loading indicator', function () { ]); }); + test('removes loading indicator when setup rejects', async function () { + const p = { + canvas, + _isSketchLoading: false + }; + const setupError = new Error('setup failed'); + + lifecycles.presetup.call(p); + + const overlay = document.createElement('canvas'); + overlay.classList.add('loading-indicator'); + container.appendChild(overlay); + p._loadingOverlay = overlay; + + const setupPromise = (async function setup() { + try { + await Promise.reject(setupError); + } + finally { + lifecycles.postsetup.call(p); + } + })(); + + let rejectedPromise; + try { + await setupPromise; + } + catch (error) { + rejectedPromise = error; + } + + assert.strictEqual(rejectedPromise, setupError); + assert.isFalse(p._isSketchLoading); + assert.isNull(container.querySelector('.loading-indicator')); + }); + test('test the loading indicator in an instance', function () { const canvas = document.createElement('canvas'); container.appendChild(canvas); @@ -197,8 +234,10 @@ suite('Loading indicator', function () { } try { await load1(2000); - } finally { + } + finally { lifecycles.postsetup.call(p1); + } })(); @@ -212,7 +251,8 @@ suite('Loading indicator', function () { } try { await load2(4000); - } finally { + } + finally { lifecycles.postsetup.call(p2); } })(); From 2ee4751cb7f9e84f7b51fe77a29ec1997635f204 Mon Sep 17 00:00:00 2001 From: Johnny Huynh Date: Fri, 11 Sep 2026 20:44:04 -0700 Subject: [PATCH 08/15] optional transparent background for loading indicator --- src/core/loading.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/loading.js b/src/core/loading.js index a67e7e74f6..93ced27469 100644 --- a/src/core/loading.js +++ b/src/core/loading.js @@ -173,6 +173,10 @@ function _removeLoadingOverlay(pInst) { function _drawLoadingIndicator(ctx, x, y, t) { let rotationSpeed = 3.25; let indicatorSize = 1.5; + + // Semi-transparent gray background + ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.save(); ctx.translate(x, y); From 113d4bce3ddce73259896cde1b99f6a1781d3f8d Mon Sep 17 00:00:00 2001 From: Johnny Huynh Date: Sat, 10 Oct 2026 17:05:25 -0700 Subject: [PATCH 09/15] disableLoadingIndicator flag added --- src/core/loading.js | 4 ++++ src/core/main.js | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/core/loading.js b/src/core/loading.js index 93ced27469..bbd9e4e1b8 100644 --- a/src/core/loading.js +++ b/src/core/loading.js @@ -59,6 +59,10 @@ export default function loading(p5, fn, lifecycles) { * @param {p5} pInst The p5 instance. */ function _createLoadingOverlay(pInst) { + if (pInst.constructor.disableLoadingIndicator) { + return; + } + if (pInst._hasCustomLoading && typeof pInst._loadingAnimation !== 'function') { return; } diff --git a/src/core/main.js b/src/core/main.js index 87f37dbde9..c91cfdec35 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -30,6 +30,7 @@ import * as constants from './constants'; * @return {p5} a p5 instance */ class p5 { + static disableLoadingIndicator = false; static VERSION = constants.VERSION; // This is a pointer to our global mode p5 instance, if we're in // global mode. @@ -400,7 +401,10 @@ class p5 { this._removeAbortController.abort(); // remove DOM elements created by p5 - this._elements.slice().forEach(e => e.remove()); + const elementsToRemove = this._elements.slice(); + for (const e of elementsToRemove) { + e.remove(); + } // Run `remove` hooks await this._runLifecycleHook('remove'); From 4bbe13b76727cfcc1e0ded1cdbe9eda720ba735f Mon Sep 17 00:00:00 2001 From: Johnny Huynh Date: Sat, 10 Oct 2026 17:23:11 -0700 Subject: [PATCH 10/15] discovered and fixed a bug where custom loading indicator is never created --- src/core/loading.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/loading.js b/src/core/loading.js index bbd9e4e1b8..45142c7a1d 100644 --- a/src/core/loading.js +++ b/src/core/loading.js @@ -37,7 +37,13 @@ export default function loading(p5, fn, lifecycles) { fn.loadingAnimation = function (callback) { this._hasCustomLoading = true; this._loadingAnimation = callback; - _removeLoadingOverlay(this); + + // Calling without a callback disables the loading animation. + // A callback replaces the animation on the next frame. + if (typeof callback !== 'function') { + _removeLoadingOverlay(this); + } + return this; }; From 7ed29a6f0adc38a26b187a4343b99550002791f3 Mon Sep 17 00:00:00 2001 From: Johnny Huynh Date: Sat, 10 Oct 2026 18:38:21 -0700 Subject: [PATCH 11/15] changed loading indicator flag and if promises are rejected in setup(), loading indicator will be cancelled --- src/core/loading.js | 2 +- src/core/main.js | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/core/loading.js b/src/core/loading.js index 45142c7a1d..e36c360b00 100644 --- a/src/core/loading.js +++ b/src/core/loading.js @@ -65,7 +65,7 @@ export default function loading(p5, fn, lifecycles) { * @param {p5} pInst The p5 instance. */ function _createLoadingOverlay(pInst) { - if (pInst.constructor.disableLoadingIndicator) { + if (pInst.constructor.loadingAnimation === false) { return; } diff --git a/src/core/main.js b/src/core/main.js index c91cfdec35..71c1e10864 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -30,7 +30,7 @@ import * as constants from './constants'; * @return {p5} a p5 instance */ class p5 { - static disableLoadingIndicator = false; + static loadingAnimation = true; static VERSION = constants.VERSION; // This is a pointer to our global mode p5 instance, if we're in // global mode. @@ -228,7 +228,16 @@ class p5 { } } - await this.#_setup(); + try { + await this.#_setup(); + } catch (error) { + // Ensure loading indicators are cleaned up if setup rejects before + // the normal postsetup lifecycle hook completes. + if (this._isSketchLoading) { + await this._runLifecycleHook('postsetup'); + } + throw error; + } if (this.hitCriticalError) return; if (!this._recording) { this._draw(); From a179189dee405a67989248a704a3ce748eb22cef Mon Sep 17 00:00:00 2001 From: Johnny Huynh Date: Sat, 10 Oct 2026 20:17:26 -0700 Subject: [PATCH 12/15] Added new and revised old loading test cases --- src/core/main.js | 5 ++-- test/unit/core/loading.js | 56 ++++++++++++++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/core/main.js b/src/core/main.js index 71c1e10864..2d82e88271 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -230,9 +230,8 @@ class p5 { try { await this.#_setup(); - } catch (error) { - // Ensure loading indicators are cleaned up if setup rejects before - // the normal postsetup lifecycle hook completes. + } + catch (error) { if (this._isSketchLoading) { await this._runLifecycleHook('postsetup'); } diff --git a/test/unit/core/loading.js b/test/unit/core/loading.js index 8b825522a1..7cdf7d65fa 100644 --- a/test/unit/core/loading.js +++ b/test/unit/core/loading.js @@ -5,13 +5,20 @@ import { mockP5, mockP5Prototype } from '../../js/mocks'; suite('Loading indicator', function () { let container; let canvas; + let createCanvasDecorator; const lifecycles = {}; beforeAll(function () { + mockP5.registerDecorator.mockImplementation((path, decorator) => { + if (path === 'p5.prototype.createCanvas') { + createCanvasDecorator = decorator; + } + }); loading(mockP5, mockP5Prototype, lifecycles); }); beforeEach(function () { + mockP5.loadingAnimation = true; container = document.createElement('div'); canvas = document.createElement('canvas'); container.appendChild(canvas); @@ -19,6 +26,7 @@ suite('Loading indicator', function () { }); afterEach(function () { + mockP5.loadingAnimation = true; if (container) { container.remove(); container = null; @@ -26,6 +34,11 @@ suite('Loading indicator', function () { } }); + function callCreateCanvas(p) { + const createCanvas = createCanvasDecorator(function () {}); + createCanvas.call(p); + } + test('shows a loading indicator while async setup waits for load()', async function () { let loadTest; @@ -53,13 +66,7 @@ suite('Loading indicator', function () { try { p.createCanvas(400, 400); - - if (p._isSketchLoading && !p._loadingOverlay) { - const overlay = document.createElement('canvas'); - overlay.classList.add('loading-indicator'); - container.appendChild(overlay); - p._loadingOverlay = overlay; - } + callCreateCanvas(p); await load(2000); @@ -172,6 +179,19 @@ suite('Loading indicator', function () { assert.strictEqual(result, p); }); + test('no loading overlay when loading animation is globally disabled', function () { + mockP5.loadingAnimation = false; + const p = { + canvas, + constructor: mockP5, + _isSketchLoading: true + }; + + callCreateCanvas(p); + + assert.isNull(container.querySelector('.loading-indicator')); + }); + test('uses a custom loading animation', function () { const callback = vi.fn(); const p = Object.assign({}, mockP5Prototype, { @@ -180,12 +200,34 @@ suite('Loading indicator', function () { }); p.loadingAnimation(callback); + callCreateCanvas(p); assert.isTrue(p._hasCustomLoading); assert.strictEqual(p._loadingAnimation, callback); + assert.isAbove(callback.mock.calls.length, 0); + assert.strictEqual(callback.mock.calls[0][0].canvas, p._loadingOverlay); + assert.strictEqual(callback.mock.calls[0][1], canvas.width); + assert.strictEqual(callback.mock.calls[0][2], canvas.height); + assert.strictEqual(callback.mock.calls[0][3], 0); assert.strictEqual(p.loadingAnimation(callback), p); }); + test('tests interactivity when loading indicator is active', function () { + const p = { + canvas, + _isSketchLoading: true + }; + + callCreateCanvas(p); + + const overlay = p._loadingOverlay; + assert.exists(overlay); + assert.strictEqual(overlay.getContext('2d').canvas, overlay); + assert.strictEqual(overlay.style.pointerEvents, 'none'); + assert.strictEqual(overlay.previousSibling, canvas); + assert.strictEqual(overlay.parentNode, canvas.parentNode); + }); + test('test multiple indicators for multiple instances', async function () { const instance1 = document.createElement('div'); const instance2 = document.createElement('div'); From 46652946dc23fdebe2db6224f86b7c1bcebe56d7 Mon Sep 17 00:00:00 2001 From: Johnny Huynh Date: Sat, 10 Oct 2026 20:27:00 -0700 Subject: [PATCH 13/15] loading try-catch comment --- src/core/main.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/main.js b/src/core/main.js index 2d82e88271..2be6269257 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -232,6 +232,8 @@ class p5 { await this.#_setup(); } catch (error) { + // Ensure loading indicator is cleaned up if setup rejects before + // the normal postsetup lifecycle hook completes if (this._isSketchLoading) { await this._runLifecycleHook('postsetup'); } From c6ab49bfa7f8159db65e4330ea4b6a16d3cd04c0 Mon Sep 17 00:00:00 2001 From: Jextic Date: Mon, 26 Oct 2026 21:22:13 -0700 Subject: [PATCH 14/15] Fixed canvas desynchronization and typo fix --- src/core/loading.js | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/core/loading.js b/src/core/loading.js index e36c360b00..e1d260607e 100644 --- a/src/core/loading.js +++ b/src/core/loading.js @@ -92,6 +92,11 @@ function _createLoadingOverlay(pInst) { if (!pInst._isSketchLoading) { return; } + + const currentCanvas = pInst.canvas?.elt || pInst.canvas; + if (currentCanvas) { + _positionCanvas(overlay, currentCanvas); + } ctx.clearRect(0, 0, overlay.width, overlay.height); const frame = frameCount++; @@ -135,28 +140,36 @@ function _createLoadingOverlay(pInst) { * @param {HTMLCanvasElement} actualCanvas The sketch canvas element. */ function _positionCanvas(loadingCanvas, actualCanvas) { - loadingCanvas.width = actualCanvas.width; - loadingCanvas.height = actualCanvas.height; + if (loadingCanvas.width !== actualCanvas.width) { + loadingCanvas.width = actualCanvas.width; + } + if (loadingCanvas.height !== actualCanvas.height) { + loadingCanvas.height = actualCanvas.height; + } const width = actualCanvas.style.width || `${actualCanvas.offsetWidth || actualCanvas.width}px`; const height = actualCanvas.style.height || `${actualCanvas.offsetHeight || actualCanvas.height}px`; + const top = `${actualCanvas.offsetTop}px`; + const left = `${actualCanvas.offsetLeft}px`; - Object.assign(loadingCanvas.style, { - width, - height, + if (loadingCanvas.style.top !== top) loadingCanvas.style.top = top; + if (loadingCanvas.style.left !== left) loadingCanvas.style.left = left; + if (loadingCanvas.style.width !== width) loadingCanvas.style.width = width; + if (loadingCanvas.style.height !== height) loadingCanvas.style.height = height; + + if (loadingCanvas.style.position !== 'absolute') { + Object.assign(loadingCanvas.style, { position: 'absolute', - top: `${actualCanvas.offsetTop}px`, - left: `${actualCanvas.offsetLeft}px`, margin: '0', padding: '0', pointerEvents: 'none', zIndex: '9999' }); + } } /** - * Stops the loading indicator animation and removes the overlay canvas from the DO - * + * Stops the loading indicator animation and removes the overlay canvas from the DOM. * @private * @param {p5} pInst The p5 instance. */ From f4f84d6e71a56aa3687ae3b6f26e77db7b5a1dfd Mon Sep 17 00:00:00 2001 From: Jextic Date: Tue, 27 Oct 2026 09:39:11 -0700 Subject: [PATCH 15/15] Test for correct overlay positions when loading --- test/unit/core/loading.js | 91 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/test/unit/core/loading.js b/test/unit/core/loading.js index 7cdf7d65fa..1dade1628b 100644 --- a/test/unit/core/loading.js +++ b/test/unit/core/loading.js @@ -228,6 +228,97 @@ suite('Loading indicator', function () { assert.strictEqual(overlay.parentNode, canvas.parentNode); }); + test('tracks canvas overlay positions during loading', function () { + let frameCallbacks = []; + const requestAnimationFrame = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation(callback => { + frameCallbacks.push(callback); + return frameCallbacks.length; + }); + + const sketches = []; + const stepFrames = () => { + const callbacks = frameCallbacks; + frameCallbacks = []; + callbacks.forEach(cb => cb()); + }; + + const makeSketch = (w, h, delay, label) => { + const c = document.createElement('canvas'); + c.width = w; + c.height = h; + c.style.width = `${w}px`; + c.style.height = `${h}px`; + c.setAttribute('data-label', label); + + let top = 0; + let left = 0; + Object.defineProperty(c, 'offsetTop', { + get: () => top, + configurable: true + }); + Object.defineProperty(c, 'offsetLeft', { + get: () => left, + configurable: true + }); + + container.appendChild(c); + + const p = { + canvas: c, + _isSketchLoading: true, + setOffset(newTop, newLeft) { + top = newTop; + left = newLeft; + } + }; + + lifecycles.presetup.call(p); + callCreateCanvas(p); + sketches.push(p); + return p; + }; + + try { + const p1 = makeSketch(600, 300, 7000, 'big 600x300'); + p1.setOffset(10, 10); + stepFrames(); + assert.strictEqual(p1._loadingOverlay.style.top, '10px'); + + const p2 = makeSketch(200, 200, 7000, 'small 200x200'); + p2.setOffset(110, 620); + stepFrames(); + assert.strictEqual(p2._loadingOverlay.style.top, '110px'); + + const p3 = makeSketch(100, 500, 7000, 'tall 100x500'); + p3.setOffset(10, 830); + p1.setOffset(210, 10); + p2.setOffset(310, 620); + + stepFrames(); + + assert.strictEqual(p1._loadingOverlay.style.top, '210px'); + assert.strictEqual(p1._loadingOverlay.style.left, '10px'); + assert.strictEqual(p1._loadingOverlay.style.width, '600px'); + assert.strictEqual(p1._loadingOverlay.style.height, '300px'); + + assert.strictEqual(p2._loadingOverlay.style.top, '310px'); + assert.strictEqual(p2._loadingOverlay.style.left, '620px'); + assert.strictEqual(p2._loadingOverlay.style.width, '200px'); + assert.strictEqual(p2._loadingOverlay.style.height, '200px'); + + assert.strictEqual(p3._loadingOverlay.style.top, '10px'); + assert.strictEqual(p3._loadingOverlay.style.left, '830px'); + assert.strictEqual(p3._loadingOverlay.style.width, '100px'); + assert.strictEqual(p3._loadingOverlay.style.height, '500px'); + } + finally { + sketches.forEach(p => lifecycles.postsetup.call(p)); + requestAnimationFrame.mockRestore(); + } + }); + test('test multiple indicators for multiple instances', async function () { const instance1 = document.createElement('div'); const instance2 = document.createElement('div');