Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion packages/editor/src/hooks/use-auto-save.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'
import { isSuspiciousNodeDrop } from './use-auto-save'
import { createStoredNodeCountTracker, isSuspiciousNodeDrop } from './use-auto-save'

describe('isSuspiciousNodeDrop', () => {
test('blocks populated scenes from being flushed as empty skeletons', () => {
Expand All @@ -12,3 +12,52 @@ describe('isSuspiciousNodeDrop', () => {
expect(isSuspiciousNodeDrop(4, 0)).toBe(false)
})
})

describe('createStoredNodeCountTracker', () => {
test('adopts a loaded graph as the baseline the guard measures against', () => {
// The hook mounts before the scene loads, so the tracker starts at the bare
// scaffold and only learns the real size when the load lands. Without this,
// every session's first save compares a populated graph against ~0 and the
// guard is dead for exactly the write that can destroy the most work.
const tracker = createStoredNodeCountTracker(0)
tracker.trackLoadedGraph(42)

expect(tracker.allowWrite(0)).toBe(false)
expect(tracker.count).toBe(42)
})

test('keeps blocking after a blocked write instead of adopting the empty graph', () => {
const tracker = createStoredNodeCountTracker(0)
tracker.trackLoadedGraph(42)

expect(tracker.allowWrite(0)).toBe(false)
// A debounced retry must not be the thing that finally lets the wipe land.
expect(tracker.allowWrite(0)).toBe(false)
})

test('lets ordinary edits through and advances the baseline', () => {
const tracker = createStoredNodeCountTracker(0)
tracker.trackLoadedGraph(12)

expect(tracker.allowWrite(13)).toBe(true)
expect(tracker.count).toBe(13)
expect(tracker.allowWrite(5)).toBe(true)
expect(tracker.count).toBe(5)
})

test('does not treat a deliberately empty new scene as suspicious', () => {
const tracker = createStoredNodeCountTracker(0)
tracker.trackLoadedGraph(4)

expect(tracker.allowWrite(0)).toBe(true)
})

test('adopts a smaller loaded graph, so restoring an older version still saves', () => {
// Loading a 3-node version over a 40-node one is not a deletion.
const tracker = createStoredNodeCountTracker(0)
tracker.trackLoadedGraph(40)
tracker.trackLoadedGraph(3)

expect(tracker.allowWrite(3)).toBe(true)
})
})
53 changes: 44 additions & 9 deletions packages/editor/src/hooks/use-auto-save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,40 @@ export function isSuspiciousNodeDrop(previousNodeCount: number, currentNodeCount
return previousNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT
}

/**
* Tracks the node count of the graph we believe is stored, which is what the
* accidental-wipe guard measures every write against.
*
* The distinction that matters: a graph that came from storage is authoritative
* and has to become the new baseline, while an edited or previewed graph must
* not. Seeding the baseline once at mount is not enough — the hook mounts
* before the scene has loaded, so it would sit at ~0 for the whole session and
* `isSuspiciousNodeDrop` could never fire.
*/
export function createStoredNodeCountTracker(initialNodeCount: number) {
let count = initialNodeCount

return {
get count() {
return count
},
/** A graph read from storage — it defines what "populated" means from here. */
trackLoadedGraph(nodeCount: number) {
count = nodeCount
},
/**
* `false` when the write would drop a populated scene to a bare scaffold,
* which is an accidental full deletion far more often than an intent. The
* caller reports the block; on `true` the write becomes the new baseline.
*/
allowWrite(nodeCount: number) {
if (isSuspiciousNodeDrop(count, nodeCount)) return false
count = nodeCount
return true
},
}
}

export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'

interface UseAutoSaveOptions {
Expand Down Expand Up @@ -65,7 +99,9 @@ export function useAutoSave({
// Stable subscription to scene changes
useEffect(() => {
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
let lastNodeCount = Object.keys(useScene.getState().nodes).length
const storedNodeCount = createStoredNodeCountTracker(
Object.keys(useScene.getState().nodes).length,
)
// Collections + scene materials are document-level state that persists with
// the graph but lives outside `nodes`. Track them by reference (zustand
// hands out a new object on every mutation) so a material edit or a
Expand All @@ -90,17 +126,15 @@ export function useAutoSave({
installedPlugins,
} as SceneGraph

// Guard: refuse to autosave if the scene went from populated to nearly empty.
// This catches accidental full deletions before they're persisted.
const currentNodeCount = Object.keys(nodes).length
if (isSuspiciousNodeDrop(lastNodeCount, currentNodeCount)) {
const previousNodeCount = storedNodeCount.count
if (!storedNodeCount.allowWrite(currentNodeCount)) {
console.warn(
`[autosave] Blocked: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
`[autosave] Blocked: scene dropped from ${previousNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
)
setSaveStatus('error')
return
}
lastNodeCount = currentNodeCount

isSavingRef.current = true
pendingSaveRef.current = false
Expand Down Expand Up @@ -135,6 +169,7 @@ export function useAutoSave({
const unsubscribe = useScene.subscribe((state) => {
if (isLoadingSceneRef.current) {
lastNodesSnapshot = JSON.stringify(state.nodes)
storedNodeCount.trackLoadedGraph(Object.keys(state.nodes).length)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Load unload disarms wipe guard

High Severity

The storedNodeCountTracker's baseline is incorrectly set to zero during scene loading. When unloadScene clears the graph, trackLoadedGraph updates the baseline before the new scene fully loads. This allows flushOnExit to persist an empty scene if the user navigates away during this loading window, bypassing the intended wipe-guard.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e797382. Configure here.

lastCollectionsRef = state.collections
lastMaterialsRef = state.materials
lastInstalledPluginsRef = state.installedPlugins
Expand Down Expand Up @@ -188,16 +223,16 @@ export function useAutoSave({
if (!hasDirtyChangesRef.current) return
const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
const currentNodeCount = Object.keys(nodes).length
if (isSuspiciousNodeDrop(lastNodeCount, currentNodeCount)) {
const previousNodeCount = storedNodeCount.count
if (!storedNodeCount.allowWrite(currentNodeCount)) {
console.warn(
`[autosave] Blocked unload flush: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
`[autosave] Blocked unload flush: scene dropped from ${previousNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
)
setSaveStatus('error')
return
}

hasDirtyChangesRef.current = false
lastNodeCount = currentNodeCount
const sceneGraph = {
nodes,
rootNodeIds,
Expand Down
Loading