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;
|
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 */}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|
||||||
cleanupCamera();
|
if (open && stream) {
|
||||||
|
console.log('[RobustCamera] Facing mode changed to', facingMode, ', reinitializing');
|
||||||
|
|
||||||
|
const reinitCamera = async () => {
|
||||||
|
cleanupCamera();
|
||||||
|
|
||||||
|
// Small delay to let cleanup complete
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
if (isCancelled || !isMountedRef.current) return;
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reinitCamera();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update ref if it hasn't been set yet
|
return () => {
|
||||||
prevFacingModeRef.current = facingMode;
|
isCancelled = true;
|
||||||
}, [facingMode, open, stream]); // Dependencies are fine - we check for actual changes inside
|
};
|
||||||
|
}, [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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user