Fix: Implement isCancelled pattern and memoize callbacks to prevent camera unmounting

- Wrap all AvatarSelector callback handlers in useCallback in CreateModal.tsx
- Add isCancelled flag pattern to RobustCamera useEffect
- Inline camera initialization to avoid stale closure issues
- Add proper cleanup on component unmount
- Ensure camera stream is properly stopped if component unmounts during initialization
- Remove unused initializeCamera function
This commit is contained in:
ajaysi
2026-04-07 11:39:07 +05:30
parent e66311ea44
commit 80838ed028
2 changed files with 158 additions and 121 deletions

View File

@@ -25,9 +25,10 @@ interface CreateModalProps {
open: boolean; open: boolean;
defaultKnobs: Knobs; defaultKnobs: Knobs;
isSubmitting?: boolean; isSubmitting?: boolean;
announcement?: string;
} }
export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaultKnobs, isSubmitting = false }) => { export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaultKnobs, isSubmitting = false, announcement }) => {
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const [topicInput, setTopicInput] = useState(""); const [topicInput, setTopicInput] = useState("");
const [showAIDetailsButton, setShowAIDetailsButton] = useState(false); const [showAIDetailsButton, setShowAIDetailsButton] = useState(false);
@@ -365,7 +366,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
setAvatarUrl(url); setAvatarUrl(url);
}, []); }, []);
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarChange = React.useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
// Validate file type // Validate file type
@@ -396,9 +397,9 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
// Continue with local preview - upload will happen on submit // Continue with local preview - upload will happen on submit
} }
} }
}; }, []);
const handleCameraSelfie = async (imageDataUrl: string) => { const handleCameraSelfie = React.useCallback(async (imageDataUrl: string) => {
try { try {
// Convert dataURL to File object // Convert dataURL to File object
const response = await fetch(imageDataUrl); const response = await fetch(imageDataUrl);
@@ -424,9 +425,9 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
} catch (error) { } catch (error) {
console.error('Failed to process selfie:', error); console.error('Failed to process selfie:', error);
} }
}; }, []);
const handleRemoveAvatar = () => { const handleRemoveAvatar = React.useCallback(() => {
setAvatarFile(null); setAvatarFile(null);
setAvatarPreview(null); setAvatarPreview(null);
setAvatarUrl(null); setAvatarUrl(null);
@@ -435,9 +436,9 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
} }
setAvatarPreviewBlobUrl(null); setAvatarPreviewBlobUrl(null);
setMakingPresentable(false); setMakingPresentable(false);
}; }, [avatarPreviewBlobUrl]);
const handleUseBrandAvatar = async () => { const handleUseBrandAvatar = React.useCallback(async () => {
if (brandAvatarFromDb) { if (brandAvatarFromDb) {
setAvatarFile(null); setAvatarFile(null);
setAvatarPreview(brandAvatarFromDb); setAvatarPreview(brandAvatarFromDb);
@@ -466,7 +467,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
} finally { } finally {
setLoadingBrandAvatar(false); setLoadingBrandAvatar(false);
} }
}; }, [brandAvatarFromDb, brandAvatarBlobUrl, loadingBrandAvatar]);
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setAvatarTab(newValue); setAvatarTab(newValue);
@@ -492,7 +493,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const handleMakePresentable = async () => { const handleMakePresentable = React.useCallback(async () => {
if (!avatarUrl || makingPresentable) return; if (!avatarUrl || makingPresentable) return;
try { try {
@@ -512,7 +513,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
} finally { } finally {
setMakingPresentable(false); setMakingPresentable(false);
} }
}; }, [avatarUrl, makingPresentable]);
return ( return (
<Paper <Paper
@@ -586,6 +587,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
submit={submit} submit={submit}
canSubmit={canSubmit} canSubmit={canSubmit}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
announcement={announcement}
/> />
{/* Enhanced Topic Choices Modal */} {/* Enhanced Topic Choices Modal */}

View File

@@ -74,81 +74,6 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
isInitializingRef.current = false; isInitializingRef.current = false;
}, [stream]); }, [stream]);
// Initialize camera - only gets the stream, doesn't attach to video
const initializeCamera = useCallback(async () => {
// Prevent double initialization
if (isInitializingRef.current) {
console.log('[RobustCamera] Already initializing, skipping');
return;
}
if (!isMountedRef.current) {
console.log('[RobustCamera] Component not mounted, skipping');
return;
}
console.log('[RobustCamera] Starting camera initialization');
isInitializingRef.current = true;
setLoading(true);
setError(null);
setCameraReady(false);
// Clean up any existing stream first
if (stream) {
console.log('[RobustCamera] Cleaning up existing stream first');
stream.getTracks().forEach(track => track.stop());
}
try {
const constraints = {
video: {
facingMode: facingMode,
width: { ideal: 1280, min: 640 },
height: { ideal: 720, min: 480 },
},
audio: false,
};
console.log('[RobustCamera] Requesting camera with constraints:', constraints);
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
if (!isMountedRef.current) {
// Component unmounted while awaiting, clean up
console.log('[RobustCamera] Component unmounted, stopping stream');
mediaStream.getTracks().forEach(track => track.stop());
return;
}
console.log('[RobustCamera] Camera stream obtained:', mediaStream.id, 'Tracks:', mediaStream.getTracks().length);
setStream(mediaStream);
setLoading(false);
} catch (err) {
console.error('[RobustCamera] Camera access error:', err);
if (!isMountedRef.current) return;
setLoading(false);
isInitializingRef.current = false;
if (err instanceof Error) {
if (err.name === 'NotAllowedError') {
setError('Camera access denied. Please allow camera permissions in your browser settings.');
} else if (err.name === 'NotFoundError') {
setError('No camera found on this device.');
} else if (err.name === 'NotReadableError') {
setError('Camera is already in use by another application. Please close other apps using the camera.');
} else if (err.name === 'OverconstrainedError') {
setError('Camera does not support the requested resolution. Please try again.');
} else {
setError(`Failed to access camera: ${err.message}`);
}
} else {
setError('Failed to access camera. Please try again.');
}
}
}, [facingMode, stream]);
// SINGLE useEffect to handle stream attachment to video // SINGLE useEffect to handle stream attachment to video
useEffect(() => { useEffect(() => {
const video = videoElementRef.current; const video = videoElementRef.current;
@@ -211,60 +136,170 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
}; };
}, [stream]); }, [stream]);
// Track previous open state to detect actual open/close changes // Initialize camera when dialog opens - using isCancelled pattern
const prevOpenRef = useRef(open);
// Initialize camera when dialog opens
useEffect(() => { useEffect(() => {
// Only initialize if dialog is opening (transition from false to true) let isCancelled = false;
if (open && !prevOpenRef.current) {
console.log('[RobustCamera] Dialog opening'); if (open) {
console.log('[RobustCamera] Dialog opened');
isMountedRef.current = true; isMountedRef.current = true;
const initCamera = async () => {
// Prevent double initialization
if (isInitializingRef.current) {
console.log('[RobustCamera] Already initializing, skipping');
return;
}
if (isCancelled) {
console.log('[RobustCamera] Cancelled before initialization');
return;
}
console.log('[RobustCamera] Starting camera initialization');
isInitializingRef.current = true;
setLoading(true);
setError(null);
setCameraReady(false);
// Clean up any existing stream first
if (stream) {
console.log('[RobustCamera] Cleaning up existing stream first');
stream.getTracks().forEach(track => track.stop());
setStream(null);
}
try {
const constraints = {
video: {
facingMode: facingMode,
width: { ideal: 1280, min: 640 },
height: { ideal: 720, min: 480 },
},
audio: false,
};
console.log('[RobustCamera] Requesting camera with constraints:', constraints);
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
// Check if cancelled or unmounted after await
if (isCancelled || !isMountedRef.current) {
console.log('[RobustCamera] Cancelled after stream obtained, stopping stream');
mediaStream.getTracks().forEach(track => track.stop());
isInitializingRef.current = false;
return;
}
console.log('[RobustCamera] Camera stream obtained:', mediaStream.id, 'Tracks:', mediaStream.getTracks().length);
setStream(mediaStream);
setLoading(false);
} catch (err) {
console.error('[RobustCamera] Camera access error:', err);
if (isCancelled || !isMountedRef.current) return;
setLoading(false);
isInitializingRef.current = false;
if (err instanceof Error) {
if (err.name === 'NotAllowedError') {
setError('Camera access denied. Please allow camera permissions in your browser settings.');
} else if (err.name === 'NotFoundError') {
setError('No camera found on this device.');
} else if (err.name === 'NotReadableError') {
setError('Camera is already in use by another application. Please close other apps using the camera.');
} else if (err.name === 'OverconstrainedError') {
setError('Camera does not support the requested resolution. Please try again.');
} else {
setError(`Failed to access camera: ${err.message}`);
}
} else {
setError('Failed to access camera. Please try again.');
}
}
};
// Small delay to ensure DOM is ready // Small delay to ensure DOM is ready
const timer = setTimeout(() => { const timer = setTimeout(() => {
initializeCamera(); if (!isCancelled) {
initCamera();
}
}, 100); }, 100);
return () => { return () => {
console.log('[RobustCamera] Cleanup from open effect');
isCancelled = true;
clearTimeout(timer); clearTimeout(timer);
}; };
} } else {
// Dialog closed - cleanup
// Only cleanup if dialog is closing (transition from true to false) console.log('[RobustCamera] Dialog closed, cleaning up');
if (!open && prevOpenRef.current) {
console.log('[RobustCamera] Dialog closing, cleaning up');
cleanupCamera(); cleanupCamera();
} }
}, [open]); // Only depend on open to prevent re-runs
// Update ref
prevOpenRef.current = open;
}, [open]); // Remove other dependencies - only react to open prop changes
// Track previous facing mode to detect actual changes
const prevFacingModeRef = useRef(facingMode);
// Handle facing mode changes // Handle facing mode changes
useEffect(() => { useEffect(() => {
// Only reinitialize if facing mode actually changed AND we have an active stream let isCancelled = false;
if (facingMode !== prevFacingModeRef.current && open && stream) {
console.log('[RobustCamera] Facing mode changed from', prevFacingModeRef.current, 'to', facingMode);
if (open && stream) {
console.log('[RobustCamera] Facing mode changed to', facingMode, ', reinitializing');
const reinitCamera = async () => {
cleanupCamera(); cleanupCamera();
const timer = setTimeout(() => { // Small delay to let cleanup complete
await new Promise(resolve => setTimeout(resolve, 300));
if (isCancelled || !isMountedRef.current) return;
isInitializingRef.current = false; isInitializingRef.current = false;
initializeCamera();
}, 300);
prevFacingModeRef.current = facingMode; // Re-initialize with new facing mode
isInitializingRef.current = true;
setLoading(true);
setError(null);
setCameraReady(false);
return () => clearTimeout(timer); try {
const constraints = {
video: {
facingMode: facingMode,
width: { ideal: 1280, min: 640 },
height: { ideal: 720, min: 480 },
},
audio: false,
};
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
if (isCancelled || !isMountedRef.current) {
mediaStream.getTracks().forEach(track => track.stop());
isInitializingRef.current = false;
return;
} }
// Update ref if it hasn't been set yet setStream(mediaStream);
prevFacingModeRef.current = facingMode; setLoading(false);
}, [facingMode, open, stream]); // Dependencies are fine - we check for actual changes inside
} catch (err) {
console.error('[RobustCamera] Camera error during flip:', err);
if (!isCancelled && isMountedRef.current) {
setLoading(false);
isInitializingRef.current = false;
setError('Failed to flip camera. Please try again.');
}
}
};
reinitCamera();
}
return () => {
isCancelled = true;
};
}, [facingMode]); // Only re-run when facingMode changes
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {
@@ -273,7 +308,7 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
isMountedRef.current = false; isMountedRef.current = false;
cleanupCamera(); cleanupCamera();
}; };
}, [cleanupCamera]); }, []);
// Capture photo // Capture photo
const capturePhoto = useCallback(() => { const capturePhoto = useCallback(() => {