Skip to content

fix: autosave no longer wipes scenes during the load window - #682

Merged
Snoopy147 merged 6 commits into
mainfrom
fix/autosave-empty-wipe
Aug 19, 2026
Merged

fix: autosave no longer wipes scenes during the load window#682
Snoopy147 merged 6 commits into
mainfrom
fix/autosave-empty-wipe

Conversation

@Snoopy147

@Snoopy147 Snoopy147 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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): useAutoSave subscribes on the initial empty store → useHostPanels' mount effect calls setInstalledPlugins before 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: PUT with 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 starts true (autosave arms only after first hydration); exit flushes route through a pure decideExitFlush() 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 409 empty_graph_rejected unless force: true.

Tests: decideExitFlush matrix 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 onSave lacks 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): isLoadingSceneRef now starts true until first hydration finishes, and unload/pagehide flushes go through decideExitFlush() 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 409 empty_graph_rejected as a wipe block (not a version conflict).

API (PUT /api/scenes/[id]): rejects empty-over-populated writes with 409 empty_graph_rejected unless the body includes force: true; shared empty-graph-guard helpers 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.

Snoopy147 and others added 6 commits August 19, 2026 00:32
… 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>
@Snoopy147
Snoopy147 merged commit 24d4c7b into main Aug 19, 2026
4 checks passed
@Snoopy147
Snoopy147 deleted the fix/autosave-empty-wipe branch August 19, 2026 05:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant