* 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>
109 lines
3.9 KiB
TypeScript
109 lines
3.9 KiB
TypeScript
'use client'
|
|
|
|
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
|
|
import { useState } from 'react'
|
|
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'
|
|
|
|
/**
|
|
* 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() {
|
|
// 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 [isNewProjectDialogOpen, setIsNewProjectDialogOpen] = useState(false)
|
|
|
|
const handleProjectSelect = async (projectId: string) => {
|
|
await setActiveProject(projectId)
|
|
}
|
|
|
|
const handleAddNew = () => {
|
|
setIsNewProjectDialogOpen(true)
|
|
}
|
|
|
|
const handleProjectCreated = async (projectId: string) => {
|
|
// Set the newly created project as active (this will also fetch projects)
|
|
await setActiveProject(projectId)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-background/95 px-3 text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50 focus:outline-none"
|
|
disabled={isLoading}
|
|
type="button"
|
|
>
|
|
<Home className="h-4 w-4" />
|
|
<span className="max-w-[150px] truncate">
|
|
{activeProject
|
|
? activeProject.name
|
|
: projects.length > 0
|
|
? 'Select Project'
|
|
: 'Add Project'}
|
|
</span>
|
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-[280px]">
|
|
{/* Project list */}
|
|
{projects.length > 0 ? (
|
|
<div className="max-h-[300px] overflow-y-auto">
|
|
{projects.map((project) => (
|
|
<DropdownMenuItem
|
|
className={cn(
|
|
'cursor-pointer text-sm',
|
|
activeProject?.id === project.id && 'cursor-default bg-accent',
|
|
)}
|
|
key={project.id}
|
|
onClick={() =>
|
|
activeProject?.id === project.id ? null : handleProjectSelect(project.id)
|
|
}
|
|
>
|
|
<div className="flex w-full items-center justify-between gap-2">
|
|
<div className="flex-1 truncate font-medium">{project.name}</div>
|
|
{activeProject?.id === project.id && (
|
|
<Check className="h-4 w-4 shrink-0 text-primary" />
|
|
)}
|
|
</div>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="px-2 py-3 text-center text-muted-foreground text-sm">
|
|
No projects yet
|
|
</div>
|
|
)}
|
|
|
|
{/* Add new project option */}
|
|
<DropdownMenuItem className="cursor-pointer" onClick={handleAddNew}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
<span>Add new project</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<NewProjectDialog
|
|
open={isNewProjectDialogOpen}
|
|
onOpenChange={setIsNewProjectDialogOpen}
|
|
onSuccess={handleProjectCreated}
|
|
/>
|
|
</>
|
|
)
|
|
}
|