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:
Anton
2026-02-20 05:01:59 +00:00
committed by GitHub
co-authored by Anton Pascal
parent 2dcb80554c
commit 557781950d
2 changed files with 102 additions and 37 deletions
@@ -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])