fix: reduce autosave function invocation spam (25x anomaly) (#108)
* 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 <anton-pascal@users.noreply.github.com>
This commit is contained in:
@@ -2,29 +2,28 @@
|
|||||||
|
|
||||||
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
|
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useProjectStore } from '../lib/projects/store'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/primitives/dropdown-menu'
|
} from '@/components/ui/primitives/dropdown-menu'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useProjectStore } from '../lib/projects/store'
|
||||||
import { NewProjectDialog } from './new-project-dialog'
|
import { NewProjectDialog } from './new-project-dialog'
|
||||||
import { useProjectScene } from '../lib/models/hooks'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ProjectDropdown - Shows active project and allows switching between projects
|
* 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() {
|
export function ProjectDropdown() {
|
||||||
useProjectScene() // Load and auto-save project scenes
|
|
||||||
|
|
||||||
// Use project store
|
// Use project store
|
||||||
const projects = useProjectStore(state => state.projects)
|
const projects = useProjectStore((state) => state.projects)
|
||||||
const activeProject = useProjectStore(state => state.activeProject)
|
const activeProject = useProjectStore((state) => state.activeProject)
|
||||||
const isLoading = useProjectStore(state => state.isLoading)
|
const isLoading = useProjectStore((state) => state.isLoading)
|
||||||
const setActiveProject = useProjectStore(state => state.setActiveProject)
|
const setActiveProject = useProjectStore((state) => state.setActiveProject)
|
||||||
const fetchProjects = useProjectStore(state => state.fetchProjects)
|
const fetchProjects = useProjectStore((state) => state.fetchProjects)
|
||||||
|
|
||||||
const [isNewProjectDialogOpen, setIsNewProjectDialogOpen] = useState(false)
|
const [isNewProjectDialogOpen, setIsNewProjectDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ import useEditor from '@/store/use-editor'
|
|||||||
import { useProjectStore } from '../projects/store'
|
import { useProjectStore } from '../projects/store'
|
||||||
import { getProjectModel, saveProjectModel } from './actions'
|
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
|
* Load the scene when a project becomes active.
|
||||||
* Saves changes automatically with debouncing
|
* 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() {
|
export function useProjectScene() {
|
||||||
// Subscribe to project store
|
// Subscribe to project store
|
||||||
@@ -24,10 +30,15 @@ export function useProjectScene() {
|
|||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||||
const isSavingRef = useRef(false)
|
const isSavingRef = useRef(false)
|
||||||
const currentProjectIdRef = useRef<string | null>(null)
|
const currentProjectIdRef = useRef<string | null>(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
|
// Extract project ID for dependency tracking
|
||||||
const projectId = activeProject?.id ?? null
|
const projectId = activeProject?.id ?? null
|
||||||
const projectName = activeProject?.name ?? null
|
|
||||||
|
|
||||||
// Load scene when active project changes
|
// Load scene when active project changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -48,6 +59,9 @@ export function useProjectScene() {
|
|||||||
|
|
||||||
// Load the project's scene
|
// Load the project's scene
|
||||||
async function loadScene() {
|
async function loadScene() {
|
||||||
|
// Suppress auto-save for the store update caused by setScene/clearScene
|
||||||
|
isLoadingSceneRef.current = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getProjectModel(projectId || '')
|
const result = await getProjectModel(projectId || '')
|
||||||
|
|
||||||
@@ -72,11 +86,19 @@ export function useProjectScene() {
|
|||||||
selectedIds: [],
|
selectedIds: [],
|
||||||
zoneId: null,
|
zoneId: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Allow auto-save again after a tick (let the store update propagate)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
isLoadingSceneRef.current = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
loadScene()
|
loadScene()
|
||||||
}, [projectId, isLoadingProject])
|
}, [projectId, isLoadingProject])
|
||||||
|
|
||||||
|
// Track whether there are unsaved changes (dirty flag for flush-on-exit).
|
||||||
|
const hasDirtyChangesRef = useRef(false)
|
||||||
|
|
||||||
// Auto-save scene changes with debouncing
|
// Auto-save scene changes with debouncing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
@@ -86,11 +108,17 @@ export function useProjectScene() {
|
|||||||
|
|
||||||
currentProjectIdRef.current = projectId
|
currentProjectIdRef.current = projectId
|
||||||
|
|
||||||
// Subscribe to any scene changes
|
// Use JSON stringification to detect node changes, not just count
|
||||||
// Use JSON stringification to detect any node changes, not just count
|
|
||||||
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
|
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
|
||||||
|
|
||||||
const unsubscribe = useScene.subscribe((state) => {
|
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)
|
const currentNodesSnapshot = JSON.stringify(state.nodes)
|
||||||
|
|
||||||
// Only trigger save if nodes actually changed
|
// Only trigger save if nodes actually changed
|
||||||
@@ -99,43 +127,81 @@ export function useProjectScene() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastNodesSnapshot = currentNodesSnapshot
|
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) {
|
if (isSavingRef.current) {
|
||||||
|
pendingSaveRef.current = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing timeout
|
// Clear existing timeout (debounce reset)
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current)
|
clearTimeout(saveTimeoutRef.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounce save by 2 seconds
|
// Debounce save
|
||||||
saveTimeoutRef.current = setTimeout(async () => {
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
// Get the current project ID at save time (not the captured value)
|
executeSave()
|
||||||
const currentProjectId = currentProjectIdRef.current
|
}, AUTOSAVE_DEBOUNCE_MS)
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 () => {
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', flushOnExit)
|
||||||
|
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current)
|
clearTimeout(saveTimeoutRef.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush on unmount (e.g. navigating away within the SPA)
|
||||||
|
flushOnExit()
|
||||||
|
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
}
|
}
|
||||||
}, [projectId])
|
}, [projectId])
|
||||||
|
|||||||
Reference in New Issue
Block a user