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