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

View File

@@ -74,81 +74,6 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
isInitializingRef.current = false;
}, [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
useEffect(() => {
const video = videoElementRef.current;
@@ -211,60 +136,170 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
};
}, [stream]);
// Track previous open state to detect actual open/close changes
const prevOpenRef = useRef(open);
// Initialize camera when dialog opens
// Initialize camera when dialog opens - using isCancelled pattern
useEffect(() => {
// Only initialize if dialog is opening (transition from false to true)
if (open && !prevOpenRef.current) {
console.log('[RobustCamera] Dialog opening');
let isCancelled = false;
if (open) {
console.log('[RobustCamera] Dialog opened');
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
const timer = setTimeout(() => {
initializeCamera();
if (!isCancelled) {
initCamera();
}
}, 100);
return () => {
console.log('[RobustCamera] Cleanup from open effect');
isCancelled = true;
clearTimeout(timer);
};
}
// Only cleanup if dialog is closing (transition from true to false)
if (!open && prevOpenRef.current) {
console.log('[RobustCamera] Dialog closing, cleaning up');
} else {
// Dialog closed - cleanup
console.log('[RobustCamera] Dialog closed, cleaning up');
cleanupCamera();
}
// Update ref
prevOpenRef.current = open;
}, [open]); // Remove other dependencies - only react to open prop changes
}, [open]); // Only depend on open to prevent re-runs
// Track previous facing mode to detect actual changes
const prevFacingModeRef = useRef(facingMode);
// Handle facing mode changes
useEffect(() => {
// Only reinitialize if facing mode actually changed AND we have an active stream
if (facingMode !== prevFacingModeRef.current && open && stream) {
console.log('[RobustCamera] Facing mode changed from', prevFacingModeRef.current, 'to', facingMode);
let isCancelled = false;
if (open && stream) {
console.log('[RobustCamera] Facing mode changed to', facingMode, ', reinitializing');
cleanupCamera();
const timer = setTimeout(() => {
const reinitCamera = async () => {
cleanupCamera();
// Small delay to let cleanup complete
await new Promise(resolve => setTimeout(resolve, 300));
if (isCancelled || !isMountedRef.current) return;
isInitializingRef.current = false;
initializeCamera();
}, 300);
// Re-initialize with new facing mode
isInitializingRef.current = true;
setLoading(true);
setError(null);
setCameraReady(false);
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;
}
setStream(mediaStream);
setLoading(false);
} 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.');
}
}
};
prevFacingModeRef.current = facingMode;
return () => clearTimeout(timer);
reinitCamera();
}
// Update ref if it hasn't been set yet
prevFacingModeRef.current = facingMode;
}, [facingMode, open, stream]); // Dependencies are fine - we check for actual changes inside
return () => {
isCancelled = true;
};
}, [facingMode]); // Only re-run when facingMode changes
// Cleanup on unmount
useEffect(() => {
@@ -273,7 +308,7 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
isMountedRef.current = false;
cleanupCamera();
};
}, [cleanupCamera]);
}, []);
// Capture photo
const capturePhoto = useCallback(() => {