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
3 changes: 3 additions & 0 deletions client/modules/IDE/actions/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
showErrorModal,
setPreviousPath
} from './ide';
import { clearLocalBackup } from '../utils/localBackup';
import { clearState, saveState } from '../../../persistState';

const ROOT_URL = getConfig('API_URL');
Expand Down Expand Up @@ -164,6 +165,8 @@ export function saveProject(
.then((response) => {
dispatch(endSavingProject());
dispatch(setUnsavedChanges(false));
// Clear the localStorage backup after successful server save (#3891)
clearLocalBackup(state.project.id);
const { hasChanges, synchedProject } = getSynchedProject(
getState(),
response.data
Expand Down
19 changes: 15 additions & 4 deletions client/modules/IDE/components/Editor/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import UnsavedChangesIndicator from '../UnsavedChangesIndicator';
import { EditorContainer, EditorHolder } from './MobileEditor';
import { FolderIcon } from '../../../../common/icons';
import { IconButton } from '../../../../common/IconButton';
import { saveLocalBackup } from '../../utils/localBackup';

import contextAwareHinter from '../../../../utils/contextAwareHinter';
import showRenameDialog from '../../../../utils/showRenameDialog';
Expand Down Expand Up @@ -217,7 +218,14 @@ class Editor extends React.Component {
this.props.setUnsavedChanges(true);
this.props.hideRuntimeErrorWarning();
this.props.updateFileContent(this.props.file.id, this._cm.getValue());
if (this.props.autorefresh && this.props.isPlaying) {

// Save a local backup to localStorage for crash recovery (#3891).
// This ensures work is recoverable even if the tab crashes
// (e.g. from an infinite loop) before the server autosave fires.
const projectId = this.props.project?.id || 'unsaved';
saveLocalBackup(projectId, this.props.files);

if (this.props.autorefresh) {
this.props.clearConsole();
this.props.startSketch();
}
Expand Down Expand Up @@ -733,7 +741,6 @@ Editor.propTypes = {
setUnsavedChanges: PropTypes.func.isRequired,
startSketch: PropTypes.func.isRequired,
autorefresh: PropTypes.bool.isRequired,
isPlaying: PropTypes.bool.isRequired,
theme: PropTypes.string.isRequired,
unsavedChanges: PropTypes.bool.isRequired,
files: PropTypes.arrayOf(
Expand All @@ -756,11 +763,15 @@ Editor.propTypes = {
provideController: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
setSelectedFile: PropTypes.func.isRequired,
expandConsole: PropTypes.func.isRequired
expandConsole: PropTypes.func.isRequired,
project: PropTypes.shape({
id: PropTypes.string
})
};

Editor.defaultProps = {
htmlFile: null
htmlFile: null,
project: {}
};

function mapStateToProps(state) {
Expand Down
1 change: 1 addition & 0 deletions client/modules/IDE/hooks/useHandleMessageEvent.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default function useHandleMessageEvent() {
if (hasInfiniteLoop) {
dispatch(stopSketch());
dispatch(expandConsole());
dispatch(dispatchConsoleEvent(decodedMessages));
return;
}

Expand Down
27 changes: 27 additions & 0 deletions client/modules/IDE/pages/IDEView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import {
clearPersistedState,
getProject
} from '../actions/project';
import { setUnsavedChanges } from '../actions/ide';
import {
getLocalBackup,
clearLocalBackup,
hasNewerLocalBackup
} from '../utils/localBackup';
import { getIsUserOwner } from '../selectors/users';
import { RootPage } from '../../../components/RootPage';
import Header from '../components/Header';
Expand Down Expand Up @@ -135,6 +141,27 @@ const IDEView = () => {
}
}, [dispatch, params, project.id]);

// Check for local backup on project load (crash recovery, #3891)
useEffect(() => {
if (!project.id || !project.updatedAt) return;

if (hasNewerLocalBackup(project.id, project.updatedAt)) {
const backup = getLocalBackup(project.id);
if (backup && backup.files) {
// Restore each file's content from the local backup
backup.files.forEach((backupFile) => {
dispatch(updateFileContent(backupFile.id, backupFile.content));
});
dispatch(setUnsavedChanges(true));
// Auto-trigger a server save so the recovered content is persisted
dispatch(autosaveProject());
}
}
// Clear the backup once the project is loaded — it has either been
// recovered or is no longer needed.
clearLocalBackup(project.id);
}, [project.id]); // eslint-disable-line

const autosaveAllowed = isUserOwner && project.id && preferences.autosave;
const shouldAutosave = autosaveAllowed && ide.unsavedChanges;

Expand Down
93 changes: 93 additions & 0 deletions client/modules/IDE/utils/localBackup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Local backup utility for crash recovery.
*
* Persists sketch file contents to localStorage on every code change,
* so users can recover their work if the browser tab crashes
* (e.g. due to an infinite loop). See issue #3891.
*/

const BACKUP_KEY_PREFIX = 'p5js-backup-';
const BACKUP_TIMESTAMP_SUFFIX = '-timestamp';

/**
* Save file contents to localStorage for a given project.
* @param {string} projectId - The project ID (or 'unsaved' for new sketches)
* @param {Array} files - Array of file objects with id, name, and content
*/
export function saveLocalBackup(projectId, files) {
if (!projectId || !files) return;

try {
const backupData = files
.filter((f) => f.content !== undefined)
.map((f) => ({
id: f.id,
name: f.name,
content: f.content
}));

const key = `${BACKUP_KEY_PREFIX}${projectId}`;
localStorage.setItem(key, JSON.stringify(backupData));
localStorage.setItem(
`${key}${BACKUP_TIMESTAMP_SUFFIX}`,
Date.now().toString()
);
} catch (e) {
// localStorage may be full or unavailable — silently fail
// since this is a best-effort recovery mechanism
}
}

/**
* Retrieve a local backup for a given project.
* @param {string} projectId
* @returns {{ files: Array, timestamp: number } | null}
*/
export function getLocalBackup(projectId) {
if (!projectId) return null;

try {
const key = `${BACKUP_KEY_PREFIX}${projectId}`;
const data = localStorage.getItem(key);
const timestamp = localStorage.getItem(`${key}${BACKUP_TIMESTAMP_SUFFIX}`);

if (!data) return null;

return {
files: JSON.parse(data),
timestamp: parseInt(timestamp, 10) || 0
};
} catch (e) {
return null;
}
}

/**
* Remove a local backup after a successful server save.
* @param {string} projectId
*/
export function clearLocalBackup(projectId) {
if (!projectId) return;

try {
const key = `${BACKUP_KEY_PREFIX}${projectId}`;
localStorage.removeItem(key);
localStorage.removeItem(`${key}${BACKUP_TIMESTAMP_SUFFIX}`);
} catch (e) {
// ignore
}
}

/**
* Check if a local backup exists and is newer than the given timestamp.
* @param {string} projectId
* @param {string|Date} lastServerSave - ISO string or Date of last server save
* @returns {boolean}
*/
export function hasNewerLocalBackup(projectId, lastServerSave) {
const backup = getLocalBackup(projectId);
if (!backup) return false;

const serverTime = lastServerSave ? new Date(lastServerSave).getTime() : 0;
return backup.timestamp > serverTime;
}
34 changes: 29 additions & 5 deletions client/modules/Preview/EmbedFrame.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import React, { useRef, useEffect, useMemo } from 'react';
import styled from 'styled-components';
import loopProtect from 'loop-protect';
import { JSHINT } from 'jshint';
import decomment from 'decomment';
import { resolvePathToFile } from '../../../server/utils/filePath';
import { getConfig } from '../../utils/getConfig';
Expand Down Expand Up @@ -58,16 +57,41 @@ function resolveCSSLinksInString(content, files) {

function jsPreprocess(jsText) {
let newContent = jsText;
// check the code for js errors before sending it to strip comments
// or loops.
JSHINT(newContent);

if (JSHINT.errors.length === 0) {
// Skip loop protection if the user explicitly opts out with // noprotect
if (/\/\/\s*noprotect/.test(newContent)) {
return newContent;
}

// Detect and fix multiple consecutive loops on the same line (e.g. "for(){}for(){}")
// which can bypass loop protection. Add semicolons between them so each loop
// is properly wrapped by loopProtect. See #3891.
// Match: for/while/do-while loops followed immediately by another loop
newContent = newContent.replace(
/((?:for|while)\s*\([^)]*\)\s*\{[^}]*\})((?:for|while)\s*\([^)]*\)\s*\{[^}]*\})/g,
'$1; $2'
);

// Always apply loop protection to prevent infinite loops from crashing
// the browser tab. Previously, loop protection was skipped when JSHINT
// found errors, but this left users vulnerable to infinite loops in
// syntactically imperfect code (common while typing). See #3891.
try {
newContent = decomment(newContent, {
ignore: /\/\/\s*noprotect/g,
space: true
});
newContent = loopProtect(newContent);
} catch (e) {
// If decomment or loopProtect fails (e.g. due to syntax issues),
// still try to apply loop protection on the original code.
try {
newContent = loopProtect(jsText);
} catch (err) {
// If loop protection can't be applied at all, return original code.
// The sketch will still run, but without loop protection.
return jsText;
}
}
return newContent;
}
Expand Down
52 changes: 51 additions & 1 deletion client/utils/previewEntry.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,58 @@ const htmlOffset = 12;
window.objectUrls[window.location.href] = '/index.html';
const blobPath = window.location.href.split('/').pop();
window.objectPaths[blobPath] = 'index.html';

// Monkey-patch loopProtect to send infinite loop warnings to the in-app console
window.loopProtect = loopProtect;
if (window.loopProtect && typeof window.loopProtect.hit === 'function') {
let hitCount = 0;
let lastHitTime = 0;
let firstLine = null;
let stopTimeout = null;
window.loopProtect.hit = function handleLoopHit(line) {
const now = Date.now();
// Reset counters if more than 1 second has passed
if (now - lastHitTime > 1000) {
hitCount = 0;
firstLine = null;
if (stopTimeout) {
clearTimeout(stopTimeout);
stopTimeout = null;
}
}
hitCount++;
lastHitTime = now;

// Track first line for single loop case
if (hitCount === 1) {
firstLine = line;
// Wait briefly to see if more loops are detected (minimal delay)
stopTimeout = setTimeout(() => {
if (hitCount === 1) {
// Only one loop detected - show line number
const msg = `Infinite loop detected at line ${firstLine}. Stopping execution.`;
throw new Error(msg);
}
// If hitCount > 1, another loop already threw the error
}, 30);
}

// If multiple loops detected, stop immediately without waiting
if (hitCount > 1) {
// Clear single loop timeout since we have multiple
if (stopTimeout) {
clearTimeout(stopTimeout);
stopTimeout = null;
}
// Stop immediately - multiple loops exist
const msg = 'Multiple infinite loops detected. Stopping execution.';
throw new Error(msg);
}

// Don't call origHit to prevent duplicate messages
// The loop protection still works, we just handle the messaging ourselves
return true; // Return true to indicate loop was detected
};
}

const consoleBuffer = [];
const LOGWAIT = 500;
Expand Down