fix: reduce store rerenders and cleanup timers

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