diff --git a/js/activity.js b/js/activity.js index 67b3c35fa0..8dd23b08f6 100644 --- a/js/activity.js +++ b/js/activity.js @@ -65,11 +65,16 @@ let MYDEFINES = [ "tweenjs.min", "preloadjs.min", "howler", - "p5.min", - "p5-sound-adapter", - "p5.dom.min", - // 'mespeak', - "Chart", + // p5.min, p5-sound-adapter, and p5.dom.min are NOT loaded eagerly. + // They are only needed by the JS-export feature and will be loaded + // on demand via require() when that feature is used, saving ~10-15 MB + // of heap memory on every page load. + // "p5.min", + // "p5-sound-adapter", + // "p5.dom.min", + // Chart.js is only used by the statistics widget and will be loaded + // on demand when the widget is opened, saving ~3-5 MB of heap memory. + // "Chart", "utils/utils", "activity/artwork", "widgets/status", @@ -2943,6 +2948,9 @@ class Activity { } }; + // Store listener reference so we can remove them in cleanup + this._idleResetHandler = resetIdleTimer; + // Track user activity window.addEventListener("mousemove", resetIdleTimer); window.addEventListener("mousedown", resetIdleTimer); @@ -2950,8 +2958,8 @@ class Activity { window.addEventListener("touchstart", resetIdleTimer); window.addEventListener("wheel", resetIdleTimer); - // Periodic check for idle state - setInterval(() => { + // Periodic check for idle state — store interval ID for cleanup + this._idleCheckInterval = setInterval(() => { // Check if music/code is playing const isMusicPlaying = this.logo?._alreadyRunning || false; @@ -2973,6 +2981,25 @@ class Activity { } }; + /** + * Removes idle watcher event listeners and clears the interval + * to prevent memory leaks when the activity is torn down. + */ + this._cleanupIdleWatcher = () => { + if (this._idleResetHandler) { + window.removeEventListener("mousemove", this._idleResetHandler); + window.removeEventListener("mousedown", this._idleResetHandler); + window.removeEventListener("keydown", this._idleResetHandler); + window.removeEventListener("touchstart", this._idleResetHandler); + window.removeEventListener("wheel", this._idleResetHandler); + this._idleResetHandler = null; + } + if (this._idleCheckInterval) { + clearInterval(this._idleCheckInterval); + this._idleCheckInterval = null; + } + }; + /* * Creates and renders error message containers with appropriate artwork. * Some error messages have special artwork. diff --git a/js/gif-animator.js b/js/gif-animator.js index 83e13ef420..6bce20eb7f 100644 --- a/js/gif-animator.js +++ b/js/gif-animator.js @@ -210,21 +210,44 @@ class GIFAnimator { } /** - * Stops and removes a specific GIF animation. + * Stops and removes a specific GIF animation, freeing its resources. */ stopAnimation(gifId) { const animation = this.animations.get(gifId); if (animation) { animation.disposed = true; + // Pause the GIF player and remove the hidden from the DOM + if (animation.gifPlayer) { + animation.gifPlayer.pause(); + } + if (animation.imgElement && animation.imgElement.parentNode) { + animation.imgElement.parentNode.removeChild(animation.imgElement); + } + // Release canvas references + animation.frameCanvas = null; + animation.frameCtx = null; + animation.gifPlayer = null; this.animations.delete(gifId); } } /** - * Stops all animations and resets internal state. + * Stops all animations and resets internal state, freeing all resources. */ stopAll() { - this.animations.forEach(anim => (anim.disposed = true)); + this.animations.forEach(anim => { + anim.disposed = true; + if (anim.gifPlayer) { + anim.gifPlayer.pause(); + } + if (anim.imgElement && anim.imgElement.parentNode) { + anim.imgElement.parentNode.removeChild(anim.imgElement); + } + // Release canvas references + anim.frameCanvas = null; + anim.frameCtx = null; + anim.gifPlayer = null; + }); this.animations.clear(); if (this.frameRequestId) { diff --git a/js/piemenus.js b/js/piemenus.js index dbaabed12f..941cbcdb84 100644 --- a/js/piemenus.js +++ b/js/piemenus.js @@ -3813,20 +3813,22 @@ const piemenuBlockContext = block => { docById("contextWheelDiv").style.display = "none"; }; - // Named function for proper cleanup - const hideContextWheelOnClick = event => { + // Use a named handler stored globally so we can remove the previous one + // before adding a new one, preventing accumulation of click listeners. + if (window._contextWheelClickHandler) { + document.body.removeEventListener("click", window._contextWheelClickHandler); + } + + window._contextWheelClickHandler = event => { const wheelElement = document.getElementById("contextWheelDiv"); const displayStyle = window.getComputedStyle(wheelElement).display; if (displayStyle === "block") { wheelElement.style.display = "none"; - // Remove listener after hiding to prevent memory leak - document.body.removeEventListener("click", hideContextWheelOnClick); + document.body.removeEventListener("click", window._contextWheelClickHandler); } }; - // Remove any existing listener before adding a new one - document.body.removeEventListener("click", hideContextWheelOnClick); - document.body.addEventListener("click", hideContextWheelOnClick); + document.body.addEventListener("click", window._contextWheelClickHandler); if ( ["customsample", "temperament1", "definemode", "show", "turtleshell", "action"].includes( diff --git a/js/widgets/statistics.js b/js/widgets/statistics.js index 0c4b6d3943..2afed582f9 100644 --- a/js/widgets/statistics.js +++ b/js/widgets/statistics.js @@ -13,7 +13,7 @@ global docById, analyzeProject, runAnalytics, scoreToChartData, - getChartOptions, Chart + getChartOptions */ /* exported StatsWindow */ @@ -36,7 +36,23 @@ class StatsWindow { this.widgetWindow.destroy(); this.activity.logo.statsWindow = null; }; - this.doAnalytics(); + + // Lazy-load Chart.js on demand instead of eagerly at startup. + // This saves ~3-5 MB of heap memory when the statistics widget + // is never opened (the common case). + // If Chart is already loaded (e.g. previously used), call synchronously. + if (typeof Chart !== "undefined") { + this.doAnalytics(); + } else { + this._ensureChartLoaded() + .then(() => { + this.doAnalytics(); + }) + .catch(err => { + // eslint-disable-next-line no-console + console.error("Failed to load Chart.js:", err); + }); + } this.widgetWindow.onmaximize = () => { this.widgetWindow.getWidgetBody().innerHTML = ""; @@ -52,6 +68,26 @@ class StatsWindow { this.widgetWindow.sendToCenter(); } + /** + * Lazily loads Chart.js via RequireJS if not already available. + * @returns {Promise} + */ + _ensureChartLoaded() { + if (typeof Chart !== "undefined") { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + // eslint-disable-next-line no-undef + require(["Chart"], () => { + if (typeof Chart !== "undefined") { + resolve(); + } else { + reject(new Error("Chart global not found after loading")); + } + }, reject); + }); + } + /** * Renders and carries out analysis of the MB project. * @public