fix(loadExample): clear currentProject before mutating stores

Critical data-loss bug. Repro:

  1. User opens a saved project at /<username>/<slug>. The page sets
     useProjectStore.currentProject = { id, slug, ownerUsername, ... }.
     Auto-save kicks in and starts watching simulator/editor stores.
  2. User clicks the "Examples" link, picks an example, hits Run.
  3. loadExample mutates useSimulatorStore (setComponents, setWires,
     addBoard, removeBoard) and useEditorStore (loadFiles).
  4. Auto-save sees the change. Its eligibility check finds
     currentProject still pointing at the user's saved project (we
     never touched useProjectStore). It debounces a
     PUT /api/projects/<old-id> with the EXAMPLE's components/wires/
     files. The user's saved project is overwritten with the example
     contents.

The URL changing to /editor isn't enough — useProjectStore is store
state, not router state. ProjectPage / ProjectByIdPage set it on
mount; nothing clears it when the user navigates away.

Fix: loadExample calls useProjectStore.getState().clearCurrentProject()
BEFORE the simulator/editor mutations. autoSaveImpl is subscribed to
useProjectStore via subscribe((s, prev) => ... reset() if id changed),
and Zustand notifies subscribers synchronously inside set(), so the
reset (projectId=null, baseline hash=null) runs in the same tick.
Every subsequent setComponents/setWires/loadFiles fires onChange in
the hook, which now sees projectId=null and returns early. No PUT
ever goes out.

The reset is order-sensitive: it must run BEFORE the store mutations
or the hook would already have queued a save with the old projectId
before we cleared. Comment in the source spells this out so it
doesn't get reordered in a future refactor.

In-flight saves are not affected: buildSavePayload() snapshots state
before its `await updateProject(...)`, so a save that started right
before the example load still sends the user's pre-example state to
the right project. Worst case: the save completes after clear, and
the hook quietly returns idle.

Build verified (vite OSS+pro, 285 SEO pages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-15 00:02:05 -03:00
parent 844083657c
commit 95f2aa9a9f
1 changed files with 17 additions and 0 deletions

View File

@ -8,6 +8,7 @@ import type { BoardKind } from '../types/board';
import { useEditorStore } from '../store/useEditorStore'; import { useEditorStore } from '../store/useEditorStore';
import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../store/useSimulatorStore'; import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../store/useSimulatorStore';
import { useElectricalStore } from '../store/useElectricalStore'; import { useElectricalStore } from '../store/useElectricalStore';
import { useProjectStore } from '../store/useProjectStore';
import { useVfsStore } from '../store/useVfsStore'; import { useVfsStore } from '../store/useVfsStore';
import { isBoardComponent } from './boardPinMapping'; import { isBoardComponent } from './boardPinMapping';
import { getInstalledLibraries, installLibrary } from '../services/libraryService'; import { getInstalledLibraries, installLibrary } from '../services/libraryService';
@ -57,6 +58,22 @@ export async function loadExample(
): Promise<void> { ): Promise<void> {
trackOpenExample(example.title); trackOpenExample(example.title);
// CRITICAL — clear currentProject FIRST, before touching any other store.
//
// Otherwise: user has a saved project open (currentProject = { id, slug, …}),
// navigates to /examples, clicks an example. We mutate the simulator +
// editor stores below; the auto-save hook is still subscribed and still
// thinks the active project is the user's saved one. It debounces a
// PUT /api/projects/<old-id> with the example's components/wires/files
// and OVERWRITES the user's saved project with the example contents.
//
// The auto-save hook is subscribed to useProjectStore and resets its
// baseline (projectId=null, lastSavedHash=null) whenever currentProject?.id
// changes. Clearing here BEFORE the mutations below guarantees the hook
// sees null as projectId during every subsequent simulator/editor change,
// so no PUT goes out.
useProjectStore.getState().clearCurrentProject();
// Loading a new example always starts unpaused — otherwise the canvas // Loading a new example always starts unpaused — otherwise the canvas
// would open with every LED frozen at the previous example's state. // would open with every LED frozen at the previous example's state.
useElectricalStore.getState().setPaused(false); useElectricalStore.getState().setPaused(false);