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:
@@ -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 */}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user