fix: Add error boundary to handle React-Quill DOM manipulation errors

Wraps RichTextEditor component with an error boundary to gracefully handle
"removeChild" errors that occur when React and Quill's DOM get out of sync.
This prevents app crashes when switching between diagrams or unmounting nodes.

The error boundary:
- Catches specific DOM manipulation errors (removeChild, insertBefore, appendChild)
- Automatically attempts recovery by re-rendering
- Prevents infinite loops with error counting
- Shows fallback UI after multiple consecutive errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Stan
2025-11-18 06:15:18 +00:00
parent 49dd5c7582
commit 6c38a11f4b
2 changed files with 139 additions and 39 deletions

View File

@@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import ReactQuill from 'react-quill-new';
import { Box } from '@mui/material';
import RichTextEditorErrorBoundary from './RichTextEditorErrorBoundary';
interface Props {
value?: string;
@@ -55,44 +56,46 @@ export const RichTextEditor = ({
}, [readOnly]);
return (
<Box
sx={{
'.ql-toolbar.ql-snow': {
border: 'none',
pt: 0,
px: 0,
pb: 1 // Add padding below toolbar to prevent overlap, might remove or make configurable at some point
},
'.ql-toolbar.ql-snow + .ql-container.ql-snow': {
border: '1px solid',
borderColor: 'grey.300',
borderTop: 'auto',
borderRadius: 1.5,
height,
color: 'text.secondary'
},
'.ql-container.ql-snow': {
...(readOnly ? { border: 'none' } : {}),
...styles
},
'.ql-editor': {
whiteSpace: 'pre-wrap', // Preserve multiple spaces and tabs
...(readOnly ? { p: 0 } : {}),
padding: '12px 15px' // Add consistent padding to prevent text overlap with tooltips
},
'.ql-tooltip': {
zIndex: 1000 // Ensure tooltips appear above content but don't obscure text
}
}}
>
<ReactQuill
theme="snow"
value={value ?? ''}
readOnly={readOnly}
onChange={onChange}
formats={formats}
modules={modules}
/>
</Box>
<RichTextEditorErrorBoundary>
<Box
sx={{
'.ql-toolbar.ql-snow': {
border: 'none',
pt: 0,
px: 0,
pb: 1 // Add padding below toolbar to prevent overlap, might remove or make configurable at some point
},
'.ql-toolbar.ql-snow + .ql-container.ql-snow': {
border: '1px solid',
borderColor: 'grey.300',
borderTop: 'auto',
borderRadius: 1.5,
height,
color: 'text.secondary'
},
'.ql-container.ql-snow': {
...(readOnly ? { border: 'none' } : {}),
...styles
},
'.ql-editor': {
whiteSpace: 'pre-wrap', // Preserve multiple spaces and tabs
...(readOnly ? { p: 0 } : {}),
padding: '12px 15px' // Add consistent padding to prevent text overlap with tooltips
},
'.ql-tooltip': {
zIndex: 1000 // Ensure tooltips appear above content but don't obscure text
}
}}
>
<ReactQuill
theme="snow"
value={value ?? ''}
readOnly={readOnly}
onChange={onChange}
formats={formats}
modules={modules}
/>
</Box>
</RichTextEditorErrorBoundary>
);
};

View File

@@ -0,0 +1,97 @@
import React, { Component, ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
errorCount: number;
}
class RichTextEditorErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
errorCount: 0
};
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> | null {
// Check if this is the specific DOM manipulation error we're trying to handle
if (
error.message.includes('removeChild') ||
error.message.includes('insertBefore') ||
error.message.includes('appendChild')
) {
// Return state update to trigger re-render
return {
hasError: true,
errorCount: 0
};
}
// For other errors, let them propagate
return null;
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log the error for debugging purposes
if (error.message.includes('removeChild') ||
error.message.includes('insertBefore') ||
error.message.includes('appendChild')) {
console.warn('RichTextEditor DOM manipulation error caught and handled:', {
message: error.message,
componentStack: errorInfo.componentStack
});
// Prevent infinite error loops by tracking error count
this.setState(prevState => ({
errorCount: prevState.errorCount + 1
}));
// If we get too many errors in a row, show fallback
if (this.state.errorCount > 3) {
console.error('Too many RichTextEditor errors, showing fallback');
return;
}
// Schedule a recovery attempt after the current render cycle
setTimeout(() => {
this.setState({
hasError: false
});
}, 0);
}
}
componentDidUpdate(_prevProps: ErrorBoundaryProps, prevState: ErrorBoundaryState) {
// Reset error state if we successfully rendered after an error
if (prevState.hasError && !this.state.hasError) {
this.setState({ errorCount: 0 });
}
}
render() {
if (this.state.hasError && this.state.errorCount > 3) {
// If too many errors, show fallback or placeholder
return this.props.fallback || (
<div style={{
padding: '10px',
border: '1px solid #ccc',
borderRadius: '4px',
backgroundColor: '#f9f9f9',
color: '#666'
}}>
Rich text editor temporarily unavailable
</div>
);
}
// Normal render or retry after error
return this.props.children;
}
}
export default RichTextEditorErrorBoundary;