From 557781950d63e90ad0520c1817f2b2f41152d5e8 Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 20 Feb 2026 05:01:59 +0000 Subject: [PATCH] fix: reduce autosave function invocation spam (25x anomaly) (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: reduce autosave function invocation spam (25x anomaly) Root cause: useProjectScene() was mounted in BOTH editor/index.tsx AND project-dropdown.tsx, creating duplicate zustand subscriptions that each independently triggered saveProjectModel() server actions. Combined with a 2-second debounce and the fact that each server action also calls getSession() (another function invocation), active editing was generating ~120+ invocations/minute. Changes: - Remove duplicate useProjectScene() from project-dropdown.tsx - Increase autosave debounce from 2s to 10s - Skip auto-save on initial scene load from server (was saving the scene right back after loading it) - Add pending-save coalescing: changes during in-flight saves get batched into one follow-up save instead of queuing concurrently * fix: flush unsaved scene to cloud on page exit / unmount Adds two safety nets so the 10s debounce doesn't lose data: - beforeunload listener: fires saveProjectModel() when the user closes the tab or navigates away externally - cleanup flush on unmount: fires when navigating within the SPA (e.g. switching projects, going to settings) Both are fire-and-forget — if the browser kills the request, localStorage still has the data and will sync on next load. * style: fix biome import ordering in project-dropdown --------- Co-authored-by: Anton Pascal --- .../community/components/project-dropdown.tsx | 19 ++- .../features/community/lib/models/hooks.ts | 120 ++++++++++++++---- 2 files changed, 102 insertions(+), 37 deletions(-) diff --git a/apps/editor/features/community/components/project-dropdown.tsx b/apps/editor/features/community/components/project-dropdown.tsx index 63bd1127..fee0a974 100644 --- a/apps/editor/features/community/components/project-dropdown.tsx +++ b/apps/editor/features/community/components/project-dropdown.tsx @@ -2,29 +2,28 @@ import { Check, ChevronDown, Home, Plus } from 'lucide-react' import { useState } from 'react' -import { useProjectStore } from '../lib/projects/store' -import { cn } from '@/lib/utils' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/primitives/dropdown-menu' +import { cn } from '@/lib/utils' +import { useProjectStore } from '../lib/projects/store' import { NewProjectDialog } from './new-project-dialog' -import { useProjectScene } from '../lib/models/hooks' /** * ProjectDropdown - Shows active project and allows switching between projects + * Note: useProjectScene() is called in the Editor component, not here. + * Having it in both places caused duplicate subscriptions and 2x server action calls. */ export function ProjectDropdown() { - useProjectScene() // Load and auto-save project scenes - // Use project store - const projects = useProjectStore(state => state.projects) - const activeProject = useProjectStore(state => state.activeProject) - const isLoading = useProjectStore(state => state.isLoading) - const setActiveProject = useProjectStore(state => state.setActiveProject) - const fetchProjects = useProjectStore(state => state.fetchProjects) + const projects = useProjectStore((state) => state.projects) + const activeProject = useProjectStore((state) => state.activeProject) + const isLoading = useProjectStore((state) => state.isLoading) + const setActiveProject = useProjectStore((state) => state.setActiveProject) + const fetchProjects = useProjectStore((state) => state.fetchProjects) const [isNewProjectDialogOpen, setIsNewProjectDialogOpen] = useState(false) diff --git a/apps/editor/features/community/lib/models/hooks.ts b/apps/editor/features/community/lib/models/hooks.ts index a69c66a8..35f02722 100644 --- a/apps/editor/features/community/lib/models/hooks.ts +++ b/apps/editor/features/community/lib/models/hooks.ts @@ -11,9 +11,15 @@ import useEditor from '@/store/use-editor' import { useProjectStore } from '../projects/store' import { getProjectModel, saveProjectModel } from './actions' +/** Debounce interval for cloud auto-save (ms). */ +const AUTOSAVE_DEBOUNCE_MS = 10_000 + /** - * Load the scene when a project becomes active - * Saves changes automatically with debouncing + * Load the scene when a project becomes active. + * Saves changes automatically with debouncing. + * + * ⚠️ This hook must be mounted in exactly ONE component (the Editor). + * Mounting it in multiple components causes duplicate save calls. */ export function useProjectScene() { // Subscribe to project store @@ -24,10 +30,15 @@ export function useProjectScene() { const saveTimeoutRef = useRef(undefined) const isSavingRef = useRef(false) const currentProjectIdRef = useRef(null) + // Track whether the scene was just loaded from the server so we can skip + // the first store update (which is the load itself, not a user edit). + const isLoadingSceneRef = useRef(false) + // Track whether there are pending changes that arrived while a save was + // in-flight so we can coalesce them into one follow-up save. + const pendingSaveRef = useRef(false) // Extract project ID for dependency tracking const projectId = activeProject?.id ?? null - const projectName = activeProject?.name ?? null // Load scene when active project changes useEffect(() => { @@ -48,6 +59,9 @@ export function useProjectScene() { // Load the project's scene async function loadScene() { + // Suppress auto-save for the store update caused by setScene/clearScene + isLoadingSceneRef.current = true + try { const result = await getProjectModel(projectId || '') @@ -72,11 +86,19 @@ export function useProjectScene() { selectedIds: [], zoneId: null, }) + + // Allow auto-save again after a tick (let the store update propagate) + requestAnimationFrame(() => { + isLoadingSceneRef.current = false + }) } loadScene() }, [projectId, isLoadingProject]) + // Track whether there are unsaved changes (dirty flag for flush-on-exit). + const hasDirtyChangesRef = useRef(false) + // Auto-save scene changes with debouncing useEffect(() => { if (!projectId) { @@ -86,11 +108,17 @@ export function useProjectScene() { currentProjectIdRef.current = projectId - // Subscribe to any scene changes - // Use JSON stringification to detect any node changes, not just count + // Use JSON stringification to detect node changes, not just count let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes) const unsubscribe = useScene.subscribe((state) => { + // Skip saves triggered by loading a scene from the server + if (isLoadingSceneRef.current) { + // Update the snapshot so the next real edit is compared correctly + lastNodesSnapshot = JSON.stringify(state.nodes) + return + } + const currentNodesSnapshot = JSON.stringify(state.nodes) // Only trigger save if nodes actually changed @@ -99,43 +127,81 @@ export function useProjectScene() { } lastNodesSnapshot = currentNodesSnapshot - const nodes = state.nodes + hasDirtyChangesRef.current = true - // Skip if currently saving + // If a save is in-flight, mark pending so we do one follow-up save + // instead of queuing unlimited concurrent saves. if (isSavingRef.current) { + pendingSaveRef.current = true return } - // Clear existing timeout + // Clear existing timeout (debounce reset) if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current) } - // Debounce save by 2 seconds - saveTimeoutRef.current = setTimeout(async () => { - // Get the current project ID at save time (not the captured value) - const currentProjectId = currentProjectIdRef.current - if (!currentProjectId) { - return - } - - const rootNodeIds = useScene.getState().rootNodeIds - const sceneGraph = { nodes, rootNodeIds } - - isSavingRef.current = true - - try { - await saveProjectModel(currentProjectId, sceneGraph) - } finally { - isSavingRef.current = false - } - }, 2000) + // Debounce save + saveTimeoutRef.current = setTimeout(() => { + executeSave() + }, AUTOSAVE_DEBOUNCE_MS) }) + async function executeSave() { + const currentProjectId = currentProjectIdRef.current + if (!currentProjectId) return + + const { nodes, rootNodeIds } = useScene.getState() + const sceneGraph = { nodes, rootNodeIds } + + isSavingRef.current = true + pendingSaveRef.current = false + + try { + await saveProjectModel(currentProjectId, sceneGraph) + hasDirtyChangesRef.current = false + } finally { + isSavingRef.current = false + + // If changes arrived while we were saving, schedule one more save + if (pendingSaveRef.current) { + pendingSaveRef.current = false + saveTimeoutRef.current = setTimeout(() => { + executeSave() + }, AUTOSAVE_DEBOUNCE_MS) + } + } + } + + // Flush unsaved changes when the user leaves the page / closes the tab. + // Uses sendBeacon via keepalive fetch so the request survives page unload. + function flushOnExit() { + if (!hasDirtyChangesRef.current || !currentProjectIdRef.current) return + + const { nodes, rootNodeIds } = useScene.getState() + const sceneGraph = { nodes, rootNodeIds } + + // Best-effort fire-and-forget save. We use the server action directly + // (it's just a POST to a Next.js endpoint). If the browser kills it, + // localStorage still has the data and will sync on next load. + saveProjectModel(currentProjectIdRef.current, sceneGraph).catch(() => { + // Swallow — nothing we can do during unload + }) + hasDirtyChangesRef.current = false + } + + window.addEventListener('beforeunload', flushOnExit) + return () => { + window.removeEventListener('beforeunload', flushOnExit) + if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current) } + + // Flush on unmount (e.g. navigating away within the SPA) + flushOnExit() + unsubscribe() } }, [projectId])