diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index c031881548..53bbcc0953 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -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'); @@ -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 diff --git a/client/modules/IDE/components/Editor/index.jsx b/client/modules/IDE/components/Editor/index.jsx index 474808da1c..8fc4284a13 100644 --- a/client/modules/IDE/components/Editor/index.jsx +++ b/client/modules/IDE/components/Editor/index.jsx @@ -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'; @@ -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(); } @@ -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( @@ -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) { diff --git a/client/modules/IDE/hooks/useHandleMessageEvent.js b/client/modules/IDE/hooks/useHandleMessageEvent.js index 5603c1d697..62c1e969d3 100644 --- a/client/modules/IDE/hooks/useHandleMessageEvent.js +++ b/client/modules/IDE/hooks/useHandleMessageEvent.js @@ -59,6 +59,7 @@ export default function useHandleMessageEvent() { if (hasInfiniteLoop) { dispatch(stopSketch()); dispatch(expandConsole()); + dispatch(dispatchConsoleEvent(decodedMessages)); return; } diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx index ec42afc1bc..968239ee6c 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -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'; @@ -136,6 +142,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; diff --git a/client/modules/IDE/utils/localBackup.js b/client/modules/IDE/utils/localBackup.js new file mode 100644 index 0000000000..39eecffcb2 --- /dev/null +++ b/client/modules/IDE/utils/localBackup.js @@ -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; +} diff --git a/client/modules/Preview/EmbedFrame.jsx b/client/modules/Preview/EmbedFrame.jsx index 2b6ac16720..50cdcdab6c 100644 --- a/client/modules/Preview/EmbedFrame.jsx +++ b/client/modules/Preview/EmbedFrame.jsx @@ -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'; @@ -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; } diff --git a/client/utils/previewEntry.js b/client/utils/previewEntry.js index 4292e5a83b..75d54e7055 100644 --- a/client/utils/previewEntry.js +++ b/client/utils/previewEntry.js @@ -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;