diff --git a/src/common/badges-dashboard/BadgeDetails.jsx b/src/common/badges-dashboard/BadgeDetails.jsx
index ff78258d9f..b20d38c65d 100644
--- a/src/common/badges-dashboard/BadgeDetails.jsx
+++ b/src/common/badges-dashboard/BadgeDetails.jsx
@@ -1,6 +1,6 @@
import Badge from './Badge';
-import sanitizeHTML from 'common/utils/sanitizeHTML';
import './badge.css';
+import sanitizeHTML from 'common/utils/sanitizeHTML';
const BadgeDetails = ({ badge, onClose }) => {
const makeClickableLinks = (badge) => {
diff --git a/src/common/playlists/PlayErrorBoundary.css b/src/common/playlists/PlayErrorBoundary.css
new file mode 100644
index 0000000000..13cd05b837
--- /dev/null
+++ b/src/common/playlists/PlayErrorBoundary.css
@@ -0,0 +1,60 @@
+.play-error-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 3rem 1.5rem;
+ text-align: center;
+ min-height: 50vh;
+}
+
+.play-error-image {
+ width: 200px;
+ height: auto;
+ margin-bottom: 1.5rem;
+ opacity: 0.8;
+}
+
+.play-error-title {
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: #333;
+ margin: 0 0 0.75rem;
+}
+
+.play-error-message {
+ font-size: 1rem;
+ color: #666;
+ max-width: 500px;
+ line-height: 1.5;
+ margin: 0 0 1.5rem;
+}
+
+.play-error-actions {
+ display: flex;
+ gap: 1rem;
+}
+
+.play-error-retry-button {
+ padding: 0.6rem 1.5rem;
+ font-size: 0.95rem;
+ font-weight: 600;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ background: #00f2fe;
+ color: #fff;
+ transition: opacity 0.2s;
+}
+
+.play-error-back-button {
+ padding: 0.6rem 1.5rem;
+ font-size: 0.95rem;
+ font-weight: 600;
+ border: 2px solid #00f2fe;
+ border-radius: 6px;
+ cursor: pointer;
+ background: transparent;
+ color: #00f2fe;
+ transition: opacity 0.2s;
+}
\ No newline at end of file
diff --git a/src/common/playlists/PlayErrorBoundary.jsx b/src/common/playlists/PlayErrorBoundary.jsx
new file mode 100644
index 0000000000..76015834f8
--- /dev/null
+++ b/src/common/playlists/PlayErrorBoundary.jsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import { ReactComponent as ImageOops } from 'images/img-oops.svg';
+import './PlayErrorBoundary.css';
+
+class PlayErrorBoundary extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = { hasError: false, error: null, isChunkError: false };
+ }
+
+ static getDerivedStateFromError(error) {
+ // Detect chunk load failures (network errors loading lazy chunks)
+ const isChunkError =
+ error?.name === 'ChunkLoadError' ||
+ /loading chunk/i.test(error?.message) ||
+ /failed to fetch dynamically imported module/i.test(error?.message);
+
+ return { hasError: true, error, isChunkError };
+ }
+
+ componentDidCatch(error, errorInfo) {
+ console.error(`Error loading play "${this.props.playName}":`, error, errorInfo);
+ }
+
+ handleRetry = () => {
+ this.setState({ hasError: false, error: null, isChunkError: false });
+ };
+
+ handleGoBack = () => {
+ window.location.href = '/plays';
+ };
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
+
+ {this.state.isChunkError ? 'Failed to load this play' : 'Something went wrong'}
+
+
+ {this.state.isChunkError
+ ? 'There was a network error loading this play. Please check your connection and try again.'
+ : `An error occurred while rendering "${this.props.playName || 'this play'}".`}
+
+
+ {this.state.isChunkError && (
+
+ )}
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+export default PlayErrorBoundary;
diff --git a/src/common/playlists/PlayMeta.jsx b/src/common/playlists/PlayMeta.jsx
index d5dbc2f17b..33158038d9 100644
--- a/src/common/playlists/PlayMeta.jsx
+++ b/src/common/playlists/PlayMeta.jsx
@@ -10,6 +10,7 @@ import { PageNotFound } from 'common';
import thumbPlay from 'images/thumb-play.png';
import { getProdUrl } from 'common/utils/commonUtils';
import { loadCoverImage } from 'common/utils/coverImageUtil';
+import PlayErrorBoundary from 'common/playlists/PlayErrorBoundary';
function PlayMeta() {
const [loading, setLoading] = useState(true);
@@ -87,6 +88,10 @@ function PlayMeta() {
const renderPlayComponent = () => {
const Comp = plays[play.component || toSanitized(play.title_name)];
+ if (!Comp) {
+ return ;
+ }
+
return ;
};
@@ -103,7 +108,11 @@ function PlayMeta() {
- }>{renderPlayComponent()}
+ }
+ >
+ {renderPlayComponent()}
+
>
);
}
diff --git a/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js b/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js
index 1eb8241875..951f4380cb 100644
--- a/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js
+++ b/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js
@@ -23,8 +23,6 @@ function SelectionSortVisualizer() {
const handleSort = async () => {
const arrCopy = [...arr];
const outputElements = document.getElementById('output-visualizer');
- // Safe: clears the container to empty string (no user data injected).
- // All subsequent DOM mutations use createElement/createTextNode (XSS-safe).
outputElements.innerHTML = '';
for (let i = 0; i < arrCopy.length - 1; i++) {
diff --git a/src/plays/devblog/Pages/Article.jsx b/src/plays/devblog/Pages/Article.jsx
index ef2ea2d2b0..3d4f3391b1 100644
--- a/src/plays/devblog/Pages/Article.jsx
+++ b/src/plays/devblog/Pages/Article.jsx
@@ -1,8 +1,8 @@
import axios from 'axios';
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
-import sanitizeHTML from 'common/utils/sanitizeHTML';
import Loading from '../components/Loading';
+import sanitizeHTML from 'common/utils/sanitizeHTML';
const Article = () => {
const [article, setArticle] = useState({});
diff --git a/src/plays/fun-quiz/EndScreen.jsx b/src/plays/fun-quiz/EndScreen.jsx
index 5398009a8b..3c30b637dc 100644
--- a/src/plays/fun-quiz/EndScreen.jsx
+++ b/src/plays/fun-quiz/EndScreen.jsx
@@ -1,5 +1,6 @@
// vendors
import { Fragment, useState } from 'react';
+
import sanitizeHTML from 'common/utils/sanitizeHTML';
// css
diff --git a/src/plays/fun-quiz/QuizScreen.jsx b/src/plays/fun-quiz/QuizScreen.jsx
index 571946985a..c0f7dd2da2 100644
--- a/src/plays/fun-quiz/QuizScreen.jsx
+++ b/src/plays/fun-quiz/QuizScreen.jsx
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback, useRef } from 'react';
-import sanitizeHTML from 'common/utils/sanitizeHTML';
+import sanitizeHTML from 'common/utils/sanitizeHTML';
import './QuizScreen.scss';
// assets
diff --git a/src/plays/markdown-editor/Output.jsx b/src/plays/markdown-editor/Output.jsx
index 8f7ca65314..9228271430 100644
--- a/src/plays/markdown-editor/Output.jsx
+++ b/src/plays/markdown-editor/Output.jsx
@@ -1,11 +1,10 @@
import React from 'react';
-import sanitizeHTML from 'common/utils/sanitizeHTML';
const Output = ({ md, text, mdPreviewBox }) => {
return (
);
diff --git a/src/plays/text-to-speech/TextToSpeech.jsx b/src/plays/text-to-speech/TextToSpeech.jsx
index e400bc4fde..26c13c6327 100644
--- a/src/plays/text-to-speech/TextToSpeech.jsx
+++ b/src/plays/text-to-speech/TextToSpeech.jsx
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { FaVolumeUp, FaStop } from 'react-icons/fa';
import PlayHeader from 'common/playlists/PlayHeader';
+import sanitizeHTML from 'common/utils/sanitizeHTML';
import './styles.css';
function TextToSpeech(props) {
@@ -158,7 +159,10 @@ function TextToSpeech(props) {
{convertedText ? (
<>
-
{convertedText}
+