From a1d2fbd96bb6713c3172198d71e94274af409aee Mon Sep 17 00:00:00 2001 From: naoNao89 <90588855+naoNao89@users.noreply.github.com> Date: Thu, 31 Jul 2025 05:29:42 +0700 Subject: [Editor] Prevent excessive project creation from invalid routes Race conditions in useEffect hook caused navigation to invalid project IDs to create multiple 'Untitled Project' entries. Users reported accumulating 885+ projects due to this bug. This change adds global invalid project ID tracking across component remounts and prevents race conditions through: - Global invalidProjectIds Set in project store with tracking functions - isInitializingRef to prevent duplicate initialization attempts - Proper useEffect cleanup with cancellation tokens - Specific error handling for 'not found' vs recoverable errors - Removed problematic activeProject?.id dependency Testing: - Add comprehensive test suite (15 tests) with Vitest + React Testing Library - Include integration tests demonstrating bug and validating fix - Update CI pipeline to run automated tests Fixes #182 --- apps/web/src/app/editor/[project_id]/page.tsx | 87 ++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 8 deletions(-) (limited to 'apps/web/src/app') diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 26fdcf8a..3378d11d 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -32,39 +32,110 @@ export default function Editor() { setPropertiesPanel, } = usePanelStore(); - const { activeProject, loadProject, createNewProject } = useProjectStore(); + const { + activeProject, + loadProject, + createNewProject, + isInvalidProjectId, + markProjectIdAsInvalid, + } = useProjectStore(); const params = useParams(); const router = useRouter(); const projectId = params.project_id as string; const handledProjectIds = useRef>(new Set()); + const isInitializingRef = useRef(false); usePlaybackControls(); useEffect(() => { + let isCancelled = false; + const initProject = async () => { - if (!projectId) return; + if (!projectId) { + return; + } + // Prevent duplicate initialization + if (isInitializingRef.current) { + return; + } + + // Check if project is already loaded if (activeProject?.id === projectId) { return; } + // Check global invalid tracking FIRST (most important for preventing duplicates) + if (isInvalidProjectId(projectId)) { + return; + } + + // Check if we've already handled this project ID locally if (handledProjectIds.current.has(projectId)) { return; } + // Mark as initializing to prevent race conditions + isInitializingRef.current = true; + handledProjectIds.current.add(projectId); + try { await loadProject(projectId); - } catch (error) { - handledProjectIds.current.add(projectId); - const newProjectId = await createNewProject("Untitled Project"); - router.replace(`/editor/${newProjectId}`); - return; + // Check if component was unmounted during async operation + if (isCancelled) { + return; + } + + // Project loaded successfully + isInitializingRef.current = false; + } catch (error) { + // Check if component was unmounted during async operation + if (isCancelled) { + return; + } + + // More specific error handling - only create new project for actual "not found" errors + const isProjectNotFound = error instanceof Error && + (error.message.includes('not found') || + error.message.includes('does not exist') || + error.message.includes('Project not found')); + + if (isProjectNotFound) { + // Mark this project ID as invalid globally BEFORE creating project + markProjectIdAsInvalid(projectId); + + try { + const newProjectId = await createNewProject("Untitled Project"); + + // Check again if component was unmounted + if (isCancelled) { + return; + } + + router.replace(`/editor/${newProjectId}`); + } catch (createError) { + console.error("Failed to create new project:", createError); + } + } else { + // For other errors (storage issues, corruption, etc.), log but don't create new project + console.error("Project loading failed with recoverable error:", error); + // Remove from handled set so user can retry + handledProjectIds.current.delete(projectId); + } + + isInitializingRef.current = false; } }; initProject(); - }, [projectId, activeProject?.id, loadProject, createNewProject, router]); + + // Cleanup function to cancel async operations + return () => { + isCancelled = true; + isInitializingRef.current = false; + }; + }, [projectId, loadProject, createNewProject, router]); // Removed activeProject?.id dependency return ( -- cgit v1.3.1