Skip to content

Save / Load / Tabs

1. Identity

The save / load / tabs workflow is the per-tab persistence layer that autosaves every FEA scene to localStorage under a tab-scoped key, exposes a deferred-restore banner on page load, lets the user export / import .motordevs.json files, and warns on beforeunload while any tab is dirty.

2. When to use it

  • The user refreshes the browser or closes the tab accidentally and expects their last scene to still be available.
  • The user wants to start a new sketch from blank without losing the previous one (the archive slot is the recovery bridge).
  • The user wants to ship a scene as a file (Save As) or open one a colleague sent.
  • The user wants several independent sketches open side-by-side via the top tab strip, each with its own undo history and autosave key.

3. Inputs

  • Tool button — File menu in TopTabStrip exposes Save / Open; per-tab close button triggers the deferred-restore lifecycle on next reload.
  • Keyboard shortcutCmd/Ctrl+SsaveSceneFile action (downloads <tab>.motordevs.json). Open has no shortcut wired today.
  • MotorScript builtinN/A (persistence is a runtime concern, not script-addressable).
  • Command Palettenot yet wired.
  • Context menuN/A.
  • Page-load bannerSavedSceneBanner (mounted in FeaEditor.tsx) auto-renders when hasSavedScene(tabId) or hasArchivedScene(tabId) is true.

4. State machine

  1. App boot. getTabStore(tabId, { deferRestore: true }) (app/src/lib/fea2d/store/index.ts:979) constructs the per-tab store. If loadSavedScene(tabId) returns an envelope, it is parked on ctx._deferredRestoreEnvelope; the store stays empty. Preview: blank canvas with SavedSceneBanner showing "Saved N min ago available — [Load saved] [Discard]". Cancel: user closes the banner by acting on it.
  2. Load saved. Click [Load saved]applyDeferredRestore (io.ts:728) sets {scene, script, params} from the cached envelope and clears history stacks. Banner mode → idle. Preview: scene materialises; autosave dirty bit stays false until the next mutation.
  3. Discard. Click [Discard]discardActiveScene (io.ts:749) calls archiveActiveScene(tabId) (envelope moved to motordevs.scene.archived.<tabId>) then clearSavedScene(tabId). Emitter fires scene-archived; banner flips to has-archive. Preview: canvas stays blank; banner now reads "Scene archived — [Restore archive] [Permanently delete]".
  4. Restore archive. Click [Restore archive]restoreArchivedScene (io.ts:759) calls moveArchiveToActive(tabId) (archive key → active key) and hydrates the store. Emitter fires archive-restored; banner → idle. The next autosave debounce will re-write the now-restored envelope under the active key.
  5. Permanent delete. Click [Permanently delete]permanentlyDeleteArchive (io.ts:781) calls clearArchivedScene(tabId). Emitter fires archive-cleared; banner → idle. The envelope is gone; no recovery.
  6. Mutate. Any mutation that changes scene, script, or params (Zustand subscribe in attachAutosave, autosave.ts:760) sets ctx.dirty = true and arms a 1.5 s setTimeout. On expiry, flush calls saveSceneNow(tabId, store) which performs an atomic three-step write (tmp key → read-back verify → commit → drop tmp).
  7. Tab hide / pagehide. visibilitychange === 'hidden' and pagehide listeners (autosave.ts:786-803) flush immediately so mobile-Safari swipe-away never loses the last 1.5 s of edits.
  8. Finalize. No explicit finalize step — autosave is continuous. Manual Save (Cmd+S) calls saveSceneFile (io.ts:626) which serialises {schemaVersion: 2, scene, params, script} to a JSON blob and triggers a download.

5. Committed state

After a successful autosave tick, localStorage[motordevs.scene.<tabId>] holds:

  • Envelope { v: 1, scene, script, params?, savedAt } (SavedScenePayload, autosave.ts:79).
  • scene is the output of state.serializeScene with _slvs* WASM handles stripped.
  • params is ParamDef[] (added in G-09-3; absent on v1-envelope blobs, defaulted to [] on read).
  • savedAt is Date.now at write time; the banner renders it via relativeTime.
  • The archive slot, if populated, lives under motordevs.scene.archived.<tabId> with the same shape.
  • File downloads (saveSceneFile) emit a SEPARATE envelope produced by serializeSceneBundle, which carries schemaVersion: 2 plus a mirrored solverStatus (the on-disk schema is NOT the autosave envelope — they overlap but differ; see §7).

6. Constraints / interactions

SurfaceBehavior
getTabStore(tabId, { deferRestore })First call per tab creates store + attaches autosave; subsequent calls return the registered instance. deferRestore is honoured ONLY on the first call.
attachAutosave debounceSingle trailing timer per tab; coalesces bursts. Re-armed on every subscribed mutation.
ctx.dragFrameDepth > 0Autosave subscription returns early — 60 FPS drag-solve frames do not trip a save per frame. The post-drag commit fires one save for the steady state.
ctx.txDepth > 0flush short-circuits mid-transaction; dirty bit stays set; next post-commit tick retries (autosave.ts:746).
anyTabDirty(getAllTabStores)Walks every registered tab's __sliceCtx.dirty; truthy → beforeunload returns a string and the browser shows its generic "Leave site?" dialog (App.tsx:42).
Cross-tab storage event for motordevs.scene.<tabId>Today: a sibling tab editing the same tabId emits a toast only. Banner reactivity across tabs is a known gap.
Cross-tab tabs-list reconcilerTopTabStrip watches motordevs.tabs.list.v2 via storage and diffs (TopTabStrip.tsx:390-456). Separate keyspace from scenes.
Schema migrationdeserializeScene (load-scene.ts:84) detects schemaVersion !== 2, calls migrateV1ToV2 , then runs the allowlist validator.
Quota latchPer-tab Set<string> in autosave.ts:129; module-state, reset only by reload. Latched tabs no-op every subsequent save.

7. Failure modes

  • QuotaExceededError on setItem. tripQuotaLatch (autosave.ts:217) routes one destructive toast through surfaceError(STORAGE_QUOTA_EXCEEDED) AND emits quota-disabled so AutosaveStatusBanner shows a sticky red strip until reload. Subsequent saves silently no-op — no toast spam.
  • Corrupt JSON in active key. loadSavedScene (autosave.ts:410) catches JSON.parse throws and routes a silent toast (FILE_LOAD_SCHEMA_ERROR); isValidSavedScenePayload also rejects envelopes with wrong v, missing scene, or malformed params. The blob is left in place (next autosave overwrites it).
  • Atomic-write verify mismatch. If localStorage.getItem(tmpKey) !== serialised (Safari private-browsing corruption), the tmp key is dropped and the previous commit-key value remains intact (autosave.ts:323).
  • Orphan tmp keys from a crashed save. pruneOrphanTmpScenes (autosave.ts:452) runs once per page load from the first getTabStore call. If commit key exists → drop tmp; if commit absent + tmp parses → promote tmp to commit (recovery).
  • File open with schemaVersion: 1. migrateV1ToV2 runs full-scene + spline CP materialisation (migrations/v1-to-v2.ts). Constraints with unsupported entityKind shapes are dropped silently and the user sees the migrated scene WITHOUT those constraints.
  • File open with invalid schema. The validator returns {error}; openSceneFile (io.ts:638) routes through surfaceError(FILE_LOAD_SCHEMA_ERROR) and the scene is unchanged.
  • Two tabs writing the same tabId. Last-write-wins on localStorage. The receiving tab sees a storage event and currently only toasts. The banner does NOT re-derive from the cross-tab write; an explicit reload is required to pick up the sibling's changes.
  • Invalid tabId (empty / non-string). Every entry point in autosave.ts guards via isValidTabId and refuses to operate; this prevents a motordevs.scene.undefined collision key.
  • Mid-transaction flush attempt. flush bails when ctx.txDepth > 0; the dirty bit stays set so the next commit triggers a retry.

8. Figures

┌──────────────────────────────────────────────────────────────┐
│ Saved 5 min ago available — start blank, or restore...       │
│                                       [Load saved] [Discard] │
└──────────────────────────────────────────────────────────────┘

Figure 1: SavedSceneBanner in has-saved mode at page load. Capture: refresh a tab that has an autosaved scene; the canvas behind is blank pending the user's choice.

┌──────────────────────────────────────────────────────────────┐
│ Scene archived — restore it, or delete it permanently.       │
│                          [Restore archive] [Permanently del] │
└──────────────────────────────────────────────────────────────┘

Figure 2: SavedSceneBanner in has-archive mode after [Discard]. Capture: from Figure 1 state, click Discard; the banner re-renders in red-accented form.

   mutate → debounce(1.5s) → flush
         │                    │
         │                    ├─ ctx.txDepth>0 ? bail, keep dirty
         │                    ├─ tmp setItem(stage)
         │                    ├─ verify read-back
         │                    ├─ commit setItem
         │                    └─ remove tmp
         └─ visibilitychange/pagehide → flush

Figure 3: Autosave debounce + atomic-write pipeline. ASCII diagram; no screenshot needed.

Saved scene availableLoad savedDiscard(blank canvas)

Figure 4 (has-saved): page-load banner offering Load saved / Discard above a blank canvas; the cached scene is parked on _deferredRestoreEnvelope until the user chooses.

Scene archivedRestore archivePermanently del

Figure 5 (has-archive): after Discard, the banner re-renders with Restore archive / Permanently delete; the canvas now holds fresh user geometry as work continues.

9. Known bugs

  • Cross-tab storage events for motordevs.scene.<tabId> emit a toast only; SavedSceneBanner does not re-render when a sibling tab writes the same key. Last-write-wins; user must reload to see the sibling tab's state.

  • migrateV1ToV2 silently drops constraints whose entityKind shape the migrator does not cover. No user-visible toast on the gap.

No P0/P1 bugs filed against the autosave / banner pipeline itself as of .

10. Class API

In the current model, the persisted envelope's scene field is the output of Scene.toJSON: SceneModelV2 (app/src/lib/fea2d/model/Scene.ts) rather than a hand-rolled snapshot serializer. On load, Scene.fromJSON(snap): Scene hydrates the class layer back from the same shape; loadScene (migrations/v1-to-v2.ts) runs first to bring legacy v1 envelopes forward to v2.

The v3 JSON persistence-stability invariant — Scene.fromJSON(scene.toJSON).toJSON the original scene.toJSON — is verified by scene-roundtrip.invariant.test.ts against both fixtures. This guarantees that the OOP refactor introduces zero byte drift in saved scene files.

motordevs studio — geometry editor specification