fix: autosave no longer wipes scenes during the load window - #682
Merged
Conversation
… autosave PUT Root cause of the 2026-08-16..18 scene-wipe class (a4993ec9f1ab, 1befee38f973, reproduced live): useAutoSave's store subscription attaches before the Editor's scene-load effect (hook order), so useHostPanels' mount-time default-installedPlugins sync marks the session dirty with zero user edits while the store still holds the empty pre-hydration state. The load effect then runs unloadScene(), whose transient 0-node write re-baselines the wipe guard to 0 via trackLoadedGraph. Any effect cleanup in that window (StrictMode simulated unmount in dev, tab close or navigation in prod) runs flushOnExit, which checked only the dirty flag — it serialized the empty store and PUT it with If-Match: 1, leaving v2 with 0 nodes. Defense in depth, all three layers: - use-auto-save: isLoadingSceneRef now starts true (autosave arms only after the first hydration completes), and the exit flush is decided by the pure decideExitFlush(), which skips any flush while a load is in flight — the store content in that window is transient, not user data. - scene-loader: tracks the server's known node count (initial meta, PUT responses, SSE events) and refuses to PUT a 0-node graph over a populated server copy, with a console.error; 409 empty_graph_rejected responses surface as a save error instead of the conflict banner. - PUT /api/scenes/[id]: rejects a 0-node graph aimed at a scene that has nodes with 409 empty_graph_rejected unless the caller passes force: true. A silent wipe is unrecoverable in place; an intentional full deletion is rare and still available via force (and every version stays in scene_revisions). Gates: decideExitFlush matrix incl. the exact traced wipe sequence, empty-graph-guard unit tests, and a route-level integration test running the real PUT handler against a temp SQLite store (409 body, store untouched after rejection, force path, empty-over-empty allowed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI resolved '@pascal-app/mcp/operations' to a build without saveScene (turbo-cached dist) — the store's save() is the stable primitive the operations layer delegates to anyway. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bun's mock.module leaks process-wide to later files in the worker — scene-store-server.test.ts stubs '@pascal-app/mcp/operations', which starved the PUT-guard fixture of saveScene/loadStoredScene in CI (single worker). Renamed to sort first + hazard comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bun's mock.module poisons the registry for every later file in the process — downstream route tests saw a stub facade without saveScene/loadStoredScene in CI. Capture + restore in afterAll. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Other test files' mock.module stubs on '@pascal-app/mcp/*' stick for later dynamic imports on linux — three CI runs starved the route fixture of saveScene/loadStoredScene while macOS passed. The fixture now builds a real SqliteSceneStore + facade from relative source paths (immune to subpath mocks) and injects them via a test-only setter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root-causes the scene-wipe class reported since 2026-08-16 (QA scenes wiped to 0 nodes at v2 after read-only sessions).
The chain (reproduced deterministically 3/3 in dev):
useAutoSavesubscribes on the initial empty store →useHostPanels' mount effect callssetInstalledPluginsbefore the scene-load effect runs, marking the store dirty with zero user edits → the load effect empties the store and re-baselines the wipe guard to 0 → the effect cleanup (StrictMode's simulated unmount in dev at t≈0.3s; any real unmount/pagehide during the load window in prod) flushes the autosave:PUTwith 0 nodes → 200 → scene wiped. The earlier "readiness timeout" hypothesis was wrong — that timeout is cosmetic and fires ~8s after the wipe.Fix, defense in depth:
use-auto-save.ts: the loading flag startstrue(autosave arms only after first hydration); exit flushes route through a puredecideExitFlush()that skips whenever a load is in flight.scene-loader.tsx: tracks the server's known node count and refuses to PUT a 0-node graph over a populated copy (error banner instead).app/api/scenes/[id]/route.ts: a 0-node PUT against a >0-node scene returns 409empty_graph_rejectedunlessforce: true.Tests:
decideExitFlushmatrix including the exact traced wipe sequence; guard unit matrix; real PUT-handler tests against a temp SQLite store (409 body, store untouched, force path). packages/editor 690 pass, apps/editor 35 pass, turbo test 13/13, types + biome clean.Live verification: isolated dev server from the fix branch — the identical read-only session fires zero PUTs, scene intact; raw empty PUT → 409,
force:true→ 200.Follow-up (separate): apps/community's own
onSavelacks the node-count guard and its API lacks the 409 layer — layer (a) protects it once this ships, but it should get (b)+(c) too.🤖 Generated with Claude Code
Note
Medium Risk
Changes scene persistence and unload flush behavior on a critical data path; normal saves should be unchanged, but mis-tuned loading flags could delay or skip legitimate exit flushes.
Overview
Fixes a scene-wipe bug where autosave could PUT a 0-node graph over a populated scene during the load window (pre-hydration dirty state + unload flush on unmount/navigation).
Editor autosave (
use-auto-save):isLoadingSceneRefnow starts true until first hydration finishes, and unload/pagehideflushes go throughdecideExitFlush()so they are skipped while a load is in flight or when a suspicious node drop would flush an empty scaffold.App save path (
scene-loader): tracks the server’s known node count, blocks client PUTs that would empty a populated scene, and treats 409empty_graph_rejectedas a wipe block (not a version conflict).API (
PUT /api/scenes/[id]): rejects empty-over-populated writes with 409empty_graph_rejectedunless the body includesforce: true; sharedempty-graph-guardhelpers drive client and server checks.Tests cover the guard matrix,
decideExitFlush(including the traced wipe sequence), integration against a real SQLite route handler, and test-only store injection / mock cleanup for stable CI.Reviewed by Cursor Bugbot for commit d2200a8. Bugbot is set up for automated code reviews on this repo. Configure here.