Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions js/activity.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -2943,15 +2948,18 @@ 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);
window.addEventListener("keydown", resetIdleTimer);
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;

Expand All @@ -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.
Expand Down
29 changes: 26 additions & 3 deletions js/gif-animator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> 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) {
Expand Down
16 changes: 9 additions & 7 deletions js/piemenus.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
40 changes: 38 additions & 2 deletions js/widgets/statistics.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
global

docById, analyzeProject, runAnalytics, scoreToChartData,
getChartOptions, Chart
getChartOptions
*/

/* exported StatsWindow */
Expand All @@ -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 = "";
Expand All @@ -52,6 +68,26 @@ class StatsWindow {
this.widgetWindow.sendToCenter();
}

/**
* Lazily loads Chart.js via RequireJS if not already available.
* @returns {Promise<void>}
*/
_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
Expand Down
Loading