From 53f6a023772d15cd9e006ef5212d11f7b71793a0 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Fri, 5 Jun 2026 17:04:01 +0800 Subject: [PATCH] fix: reduce store rerenders and cleanup timers --- src/App.tsx | 160 ++++++++++++------ src/features/canvas/CanvasPage.tsx | 40 ++++- src/features/ecommerce/EcommercePage.tsx | 2 + .../ecommerce/EcommerceVideoWorkspace.tsx | 17 +- src/features/home/ScriptReviewShowcase.tsx | 19 ++- src/hooks/useGenerationTasks.ts | 47 +++-- 6 files changed, 210 insertions(+), 75 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 91e878f..5ee6fc3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { WalletOutlined, } from "@ant-design/icons"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; import ErrorBoundary from "./components/ErrorBoundary"; import { reportError } from "./utils/errorReporting"; import { initNotificationPermission } from "./utils/generationNotifier"; @@ -233,61 +234,122 @@ function App() { const canvasAutoOpenedRecentRef = useRef(false); // Session store - const session = useSessionStore((s) => s.session); - const loginPromptOpen = useSessionStore((s) => s.loginPromptOpen); - const pendingAction = useSessionStore((s) => s.pendingAction); - const sessionReplacedOpen = useSessionStore((s) => s.sessionReplacedOpen); - const sessionReplacedMessage = useSessionStore((s) => s.sessionReplacedMessage); - const setSession = useSessionStore((s) => s.setSession); - const openLoginPrompt = useSessionStore((s) => s.openLoginPrompt); - const closeLoginPrompt = useSessionStore((s) => s.closeLoginPrompt); - const showSessionReplaced = useSessionStore((s) => s.showSessionReplaced); - const hideSessionReplaced = useSessionStore((s) => s.hideSessionReplaced); - const clearSessionState = useSessionStore((s) => s.clearSession); + const { + session, + loginPromptOpen, + pendingAction, + sessionReplacedOpen, + sessionReplacedMessage, + setSession, + openLoginPrompt, + closeLoginPrompt, + showSessionReplaced, + hideSessionReplaced, + clearSession: clearSessionState, + } = useSessionStore(useShallow((s) => ({ + session: s.session, + loginPromptOpen: s.loginPromptOpen, + pendingAction: s.pendingAction, + sessionReplacedOpen: s.sessionReplacedOpen, + sessionReplacedMessage: s.sessionReplacedMessage, + setSession: s.setSession, + openLoginPrompt: s.openLoginPrompt, + closeLoginPrompt: s.closeLoginPrompt, + showSessionReplaced: s.showSessionReplaced, + hideSessionReplaced: s.hideSessionReplaced, + clearSession: s.clearSession, + }))); // Project store - const projects = useProjectStore((s) => s.projects); - const projectsLoaded = useProjectStore((s) => s.projectsLoaded); - const canvasWorkflow = useProjectStore((s) => s.canvasWorkflow); - const currentCanvasProjectId = useProjectStore((s) => s.currentCanvasProjectId); - const pendingDeleteProject = useProjectStore((s) => s.pendingDeleteProject); - const deleteProjectSubmitting = useProjectStore((s) => s.deleteProjectSubmitting); - const setProjects = useProjectStore((s) => s.setProjects); - const setProjectsLoaded = useProjectStore((s) => s.setProjectsLoaded); - const setCanvasWorkflow = useProjectStore((s) => s.setCanvasWorkflow); - const setCurrentCanvasProjectId = useProjectStore((s) => s.setCurrentCanvasProjectId); - const openDeleteProjectModal = useProjectStore((s) => s.openDeleteProject); - const closeDeleteProjectModal = useProjectStore((s) => s.closeDeleteProject); - const setDeleteProjectSubmitting = useProjectStore((s) => s.setDeleteProjectSubmitting); - const clearProjectState = useProjectStore((s) => s.clearProjectState); + const { + projects, + projectsLoaded, + canvasWorkflow, + currentCanvasProjectId, + pendingDeleteProject, + deleteProjectSubmitting, + setProjects, + setProjectsLoaded, + setCanvasWorkflow, + setCurrentCanvasProjectId, + openDeleteProject: openDeleteProjectModal, + closeDeleteProject: closeDeleteProjectModal, + setDeleteProjectSubmitting, + clearProjectState, + } = useProjectStore(useShallow((s) => ({ + projects: s.projects, + projectsLoaded: s.projectsLoaded, + canvasWorkflow: s.canvasWorkflow, + currentCanvasProjectId: s.currentCanvasProjectId, + pendingDeleteProject: s.pendingDeleteProject, + deleteProjectSubmitting: s.deleteProjectSubmitting, + setProjects: s.setProjects, + setProjectsLoaded: s.setProjectsLoaded, + setCanvasWorkflow: s.setCanvasWorkflow, + setCurrentCanvasProjectId: s.setCurrentCanvasProjectId, + openDeleteProject: s.openDeleteProject, + closeDeleteProject: s.closeDeleteProject, + setDeleteProjectSubmitting: s.setDeleteProjectSubmitting, + clearProjectState: s.clearProjectState, + }))); // Task store - const tasks = useTaskStore((s) => s.tasks); - const appendTask = useTaskStore((s) => s.appendTask); - const mergeServerTasks = useTaskStore((s) => s.mergeServerTasks); - const clearTasks = useTaskStore((s) => s.clearTasks); + const { + tasks, + appendTask, + mergeServerTasks, + clearTasks, + } = useTaskStore(useShallow((s) => ({ + tasks: s.tasks, + appendTask: s.appendTask, + mergeServerTasks: s.mergeServerTasks, + clearTasks: s.clearTasks, + }))); // App store - const usage = useAppStore((s) => s.usage); - const runtimeNotifications = useAppStore((s) => s.runtimeNotifications); - const serverNotifications = useAppStore((s) => s.serverNotifications); - const activeView = useAppStore((s) => s.activeView); - const workspaceExpanded = useAppStore((s) => s.workspaceExpanded); - const imageWorkbenchTool = useAppStore((s) => s.imageWorkbenchTool); - const pendingEcommerceTemplate = useAppStore((s) => s.pendingEcommerceTemplate); - const backendHealth = useAppStore((s) => s.backendHealth); - const setUsage = useAppStore((s) => s.setUsage); - const pushNotification = useAppStore((s) => s.pushNotification); - const setRuntimeNotifications = useAppStore((s) => s.setRuntimeNotifications); - const setServerNotifications = useAppStore((s) => s.setServerNotifications); - const setView = useAppStore((s) => s.setView); - const setWorkspaceExpanded = useAppStore((s) => s.setWorkspaceExpanded); - const setImageWorkbenchTool = useAppStore((s) => s.setImageWorkbenchTool); - const setPendingEcommerceTemplate = useAppStore((s) => s.setPendingEcommerceTemplate); - const setBackendHealth = useAppStore((s) => s.setBackendHealth); - const markNotificationRead = useAppStore((s) => s.markNotificationRead); - const markAllNotificationsRead = useAppStore((s) => s.markAllNotificationsRead); - const clearAppState = useAppStore((s) => s.clearAppState); + const { + usage, + runtimeNotifications, + serverNotifications, + activeView, + workspaceExpanded, + imageWorkbenchTool, + pendingEcommerceTemplate, + backendHealth, + setUsage, + pushNotification, + setRuntimeNotifications, + setServerNotifications, + setView, + setWorkspaceExpanded, + setImageWorkbenchTool, + setPendingEcommerceTemplate, + setBackendHealth, + markNotificationRead, + markAllNotificationsRead, + clearAppState, + } = useAppStore(useShallow((s) => ({ + usage: s.usage, + runtimeNotifications: s.runtimeNotifications, + serverNotifications: s.serverNotifications, + activeView: s.activeView, + workspaceExpanded: s.workspaceExpanded, + imageWorkbenchTool: s.imageWorkbenchTool, + pendingEcommerceTemplate: s.pendingEcommerceTemplate, + backendHealth: s.backendHealth, + setUsage: s.setUsage, + pushNotification: s.pushNotification, + setRuntimeNotifications: s.setRuntimeNotifications, + setServerNotifications: s.setServerNotifications, + setView: s.setView, + setWorkspaceExpanded: s.setWorkspaceExpanded, + setImageWorkbenchTool: s.setImageWorkbenchTool, + setPendingEcommerceTemplate: s.setPendingEcommerceTemplate, + setBackendHealth: s.setBackendHealth, + markNotificationRead: s.markNotificationRead, + markAllNotificationsRead: s.markAllNotificationsRead, + clearAppState: s.clearAppState, + }))); const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false); const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub"; diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index 2ecf3b2..f3988d8 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -397,10 +397,12 @@ function CanvasPage({ const suppressNextPaneClickRef = useRef(false); const canvasAutoSaveTimerRef = useRef(null); const canvasAutoSaveIdleHandleRef = useRef(null); + const canvasAutoSaveRetryTimerRef = useRef(null); const canvasAutoSaveInFlightRef = useRef(false); const canvasAutoSavePendingRef = useRef(false); const lastAutoSavedWorkflowFingerprintRef = useRef(""); const canvasAutoSaveHydrationRef = useRef(true); + const textNodeMentionFocusTimerRef = useRef(null); const textNodeIdRef = useRef(9); const imageNodeIdRef = useRef(1); const videoNodeIdRef = useRef(1); @@ -519,7 +521,11 @@ function CanvasPage({ else if (kind === "video") updateVideoNodePrompt(nodeId, nextValue); else updateTextNodePrompt(nodeId, nextValue); closeTextNodeMention(nodeId); - setTimeout(() => { + if (textNodeMentionFocusTimerRef.current !== null) { + window.clearTimeout(textNodeMentionFocusTimerRef.current); + } + textNodeMentionFocusTimerRef.current = window.setTimeout(() => { + textNodeMentionFocusTimerRef.current = null; if (textarea) { textarea.focus(); textarea.setSelectionRange(nextCaret, nextCaret); @@ -555,6 +561,18 @@ function CanvasPage({ const [autoSaveStatus, setAutoSaveStatus] = useState<"saved" | "saving" | "error" | "idle">("idle"); const autoSaveStatusTimerRef = useRef(null); + useEffect(() => { + return () => { + if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current); + if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current); + if (autoSaveStatusTimerRef.current !== null) window.clearTimeout(autoSaveStatusTimerRef.current); + if (textNodeMentionFocusTimerRef.current !== null) window.clearTimeout(textNodeMentionFocusTimerRef.current); + if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) { + window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current); + } + }; + }, []); + // Save immediately when user leaves page or switches tab (placed after runCanvasAutoSave definition) // — see useEffect below near runCanvasAutoSave @@ -3159,7 +3177,13 @@ function CanvasPage({ canvasAutoSaveInFlightRef.current = false; if (canvasAutoSavePendingRef.current) { canvasAutoSavePendingRef.current = false; - window.setTimeout(() => void runCanvasAutoSave(), canvasAutoSaveIdleTimeoutMs); + if (canvasAutoSaveRetryTimerRef.current !== null) { + window.clearTimeout(canvasAutoSaveRetryTimerRef.current); + } + canvasAutoSaveRetryTimerRef.current = window.setTimeout(() => { + canvasAutoSaveRetryTimerRef.current = null; + void runCanvasAutoSave(); + }, canvasAutoSaveIdleTimeoutMs); } } }, [ @@ -3228,7 +3252,13 @@ function CanvasPage({ ); return; } - window.setTimeout(() => void runCanvasAutoSave(), canvasAutoSaveIdleTimeoutMs); + if (canvasAutoSaveRetryTimerRef.current !== null) { + window.clearTimeout(canvasAutoSaveRetryTimerRef.current); + } + canvasAutoSaveRetryTimerRef.current = window.setTimeout(() => { + canvasAutoSaveRetryTimerRef.current = null; + void runCanvasAutoSave(); + }, canvasAutoSaveIdleTimeoutMs); }, canvasAutoSaveDebounceMs); return () => { @@ -3240,6 +3270,10 @@ function CanvasPage({ window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current); canvasAutoSaveIdleHandleRef.current = null; } + if (canvasAutoSaveRetryTimerRef.current !== null) { + window.clearTimeout(canvasAutoSaveRetryTimerRef.current); + canvasAutoSaveRetryTimerRef.current = null; + } }; }, [ isAuthenticated, diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index cf61780..6fb7493 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -1098,6 +1098,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }; const clearCloneSetCountHold = () => { + window.removeEventListener("pointerup", clearCloneSetCountHold); + window.removeEventListener("pointercancel", clearCloneSetCountHold); if (countHoldTimeoutRef.current !== null) { window.clearTimeout(countHoldTimeoutRef.current); countHoldTimeoutRef.current = null; diff --git a/src/features/ecommerce/EcommerceVideoWorkspace.tsx b/src/features/ecommerce/EcommerceVideoWorkspace.tsx index 0fd211c..0623183 100644 --- a/src/features/ecommerce/EcommerceVideoWorkspace.tsx +++ b/src/features/ecommerce/EcommerceVideoWorkspace.tsx @@ -121,6 +121,7 @@ export default function EcommerceVideoWorkspace({ const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null); const abortControllerRef = useRef(null); const renderAbortRef = useRef({ current: false }); + const actionNoticeTimerRef = useRef(null); const setView = useAppStore((s) => s.setView); const keepaliveRestoredFingerprintRef = useRef(null); const keepalivePollingStartedRef = useRef(false); @@ -276,9 +277,23 @@ export default function EcommerceVideoWorkspace({ // Note: keep-alive is NOT cleared on completion — results persist across page switches. // Only cleared when user explicitly starts a new plan via handlePlan. + useEffect(() => { + return () => { + if (actionNoticeTimerRef.current !== null) { + window.clearTimeout(actionNoticeTimerRef.current); + } + }; + }, []); + const showNotice = (msg: string) => { setActionNotice(msg); - setTimeout(() => setActionNotice(null), 3000); + if (actionNoticeTimerRef.current !== null) { + window.clearTimeout(actionNoticeTimerRef.current); + } + actionNoticeTimerRef.current = window.setTimeout(() => { + actionNoticeTimerRef.current = null; + setActionNotice(null); + }, 3000); }; const handleDownload = async (url: string) => { diff --git a/src/features/home/ScriptReviewShowcase.tsx b/src/features/home/ScriptReviewShowcase.tsx index a804800..5435bef 100644 --- a/src/features/home/ScriptReviewShowcase.tsx +++ b/src/features/home/ScriptReviewShowcase.tsx @@ -50,6 +50,12 @@ function ScriptReviewShowcase() { const scoreRef = useRef(null); const barRefs = useRef<(HTMLDivElement | null)[]>([]); const scoreValRefs = useRef<(HTMLSpanElement | null)[]>([]); + const animationTimersRef = useRef[]>([]); + + const clearAnimationTimers = () => { + animationTimersRef.current.forEach((timer) => clearTimeout(timer)); + animationTimersRef.current = []; + }; useEffect(() => { const el = document.getElementById("script-review-showcase"); @@ -69,18 +75,23 @@ function ScriptReviewShowcase() { useEffect(() => { if (!animated) return; - const timer = setTimeout(() => { + clearAnimationTimers(); + const scheduleAnimation = (callback: () => void, delay: number) => { + const timer = setTimeout(callback, delay); + animationTimersRef.current.push(timer); + }; + scheduleAnimation(() => { animateNumber(scoreRef.current, 77, 1400); barRefs.current.forEach((bar, i) => { if (!bar) return; const pct = parseFloat(bar.dataset.pct ?? "0"); - setTimeout(() => { bar.style.height = `${pct}%`; }, i * 100 + 400); + scheduleAnimation(() => { bar.style.height = `${pct}%`; }, i * 100 + 400); }); scoreValRefs.current.forEach((el, i) => { - setTimeout(() => animateNumber(el, parseInt(el?.dataset.target ?? "0"), 800), i * 100 + 400); + scheduleAnimation(() => animateNumber(el, parseInt(el?.dataset.target ?? "0"), 800), i * 100 + 400); }); }, 500); - return () => clearTimeout(timer); + return clearAnimationTimers; }, [animated]); return ( diff --git a/src/hooks/useGenerationTasks.ts b/src/hooks/useGenerationTasks.ts index 7b4fd27..bab3c20 100644 --- a/src/hooks/useGenerationTasks.ts +++ b/src/hooks/useGenerationTasks.ts @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useCallback } from "react"; +import { useShallow } from "zustand/react/shallow"; import type { GenerationQueueItem } from "../stores/useGenerationStore"; import { useGenerationStore } from "../stores/useGenerationStore"; import { @@ -13,7 +14,17 @@ interface UseGenerationTasksOptions { export function useGenerationTasks(options: UseGenerationTasksOptions) { const { sourceView, autoResume = true } = options; - const store = useGenerationStore(); + const { + queue, + addTask, + updateTask: updateStoredTask, + getRunningTasks, + } = useGenerationStore(useShallow((s) => ({ + queue: s.queue, + addTask: s.addTask, + updateTask: s.updateTask, + getRunningTasks: s.getRunningTasks, + }))); const pollingStartedRef = useRef(false); // ── Auto-resume: re-subscribe to running tasks on mount ──── @@ -21,7 +32,7 @@ export function useGenerationTasks(options: UseGenerationTasksOptions) { if (!autoResume || pollingStartedRef.current) return; pollingStartedRef.current = true; - const active = store.getRunningTasks().filter((t) => t.sourceView === sourceView); + const active = getRunningTasks().filter((t) => t.sourceView === sourceView); if (active.length > 0) { startBackgroundPolling(); } @@ -29,19 +40,19 @@ export function useGenerationTasks(options: UseGenerationTasksOptions) { return () => { pollingStartedRef.current = false; }; - }, [autoResume, sourceView, store]); + }, [autoResume, sourceView, getRunningTasks]); // ── Subscribe to live updates ─────────────────────────── useEffect(() => { return subscribeToTaskUpdates((updated) => { - store.updateTask(updated.id, updated); + updateStoredTask(updated.id, updated); }); - }, [store]); + }, [updateStoredTask]); // ── View-scoped computed lists ────────────────────────── const myTasks = useMemo( - () => store.queue.filter((t) => t.sourceView === sourceView), - [store.queue, sourceView], + () => queue.filter((t) => t.sourceView === sourceView), + [queue, sourceView], ); const activeTasks = useMemo( @@ -63,41 +74,41 @@ export function useGenerationTasks(options: UseGenerationTasksOptions) { const submitTask = useCallback( (task: Omit) => { const id = `gen-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - store.addTask({ ...task, id, createdAt: Date.now() }); + addTask({ ...task, id, createdAt: Date.now() }); return id; }, - [store], + [addTask], ); const updateTask = useCallback( (id: string, patch: Partial) => { - store.updateTask(id, patch); + updateStoredTask(id, patch); }, - [store], + [updateStoredTask], ); const markCompleted = useCallback( (id: string, resultUrl: string) => { - store.updateTask(id, { status: "completed", progress: 100, resultUrl }); + updateStoredTask(id, { status: "completed", progress: 100, resultUrl }); }, - [store], + [updateStoredTask], ); const markFailed = useCallback( (id: string, error: string) => { - store.updateTask(id, { status: "failed", error }); + updateStoredTask(id, { status: "failed", error }); }, - [store], + [updateStoredTask], ); const retryTask = useCallback( (id: string) => { - const task = store.queue.find((t) => t.id === id); + const task = queue.find((t) => t.id === id); if (task) { - store.updateTask(id, { status: "pending", progress: 0, error: null }); + updateStoredTask(id, { status: "pending", progress: 0, error: null }); } }, - [store], + [queue, updateStoredTask], ); return {