--- name: save-load-json description: > Save and load an application's complete state as JSON (browser apps with download/upload, Python/Node programs with files): one state object, a serializer that saves EVERYTHING except an explicit runtime-only list, a loader that rebuilds through the normal constructors, a version field, and a round-trip check that fails when any field is lost. Use whenever a task says save/load, export/import, persist, project file, settings file, or when save/load "loses" data. --- # Save / load with JSON The bug this skill prevents: a hand-written `toJSON` that copies a few fields (`id`, `type`) and silently drops the rest (`position`, `children`, ...). The user saves, loads, and the scene is wrong. The fix is a rule, not care: > **Save everything by default.** Serialize the whole state object and > subtract an explicit `RUNTIME` list (meshes, DOM nodes, caches, selection). > A forgotten field is then saved, not lost. ## Design (do this before writing the app) 1. **One state object** holds all data the user would expect to get back: objects, their parameters and transforms, nesting, settings (grid size, units, colors), view (camera) if restoring it is useful. 2. **Data vs runtime.** Each object is a plain record (`{id, type, params, position, rotation, scale, color, hole, children}`); runtime things (`mesh`, `element`, `texture`, listeners) live in a side map `runtime.get(id)` or in fields named in `RUNTIME`. Never store a live object inside the data record if you can avoid it. 3. **One constructor path.** `fromRecord(rec)` creates a runtime object from a data record. The "add shape" button, paste, duplicate AND load all call `fromRecord` - so load can't behave differently from add. 4. **Format:** `{ "version": 1, "app": "", "state": {...} }`. Bump `version` when the shape changes; `load` migrates older versions (`migrate(data)`) instead of failing. Numbers as numbers (not strings), arrays for vectors (`[x, y, z]`), ISO strings for dates, no `NaN`/`Infinity` (JSON has none - clamp or null them). 5. **Round trip is the test:** `deepEqual(toData(fromData(toData(state))), toData(state))`, and the field-completeness check below. Run both before wiring any button. ## Browser app - `toData()` returns plain data; `download('x.json', JSON.stringify(toData(), null, 2))` is the ENTIRE save handler. `pickFile('.json', true, text => load(JSON.parse(text)))` is the entire load handler. No second serializer in a handler, ever. - Test the real buttons with the browser-test skill (`h.download('#save')`, `h.upload('#load', 'fixtures/scene.json')`) and compare the file's fields to the live state - that catches the handler calling the wrong function. - Snippets: [js.md](./js.md) (`toData`/`load` with RUNTIME subtraction, migrate, Set/Map/typed arrays, the completeness check, fixture generation). ## Python / Node programs - Python: dataclasses + `dataclasses.asdict` for the data side, a `RUNTIME` tuple of field names to drop, `json.dump(..., indent=2, ensure_ascii=False)`, write to a temp file and `os.replace` so a crash never leaves a half file. Snippets and the round-trip test: [python.md](./python.md). - Node: same rules; `JSON.stringify(data, null, 2)` + `fs.writeFileSync(tmp)` + `fs.renameSync`. ## Completeness check (run it, don't eyeball it) `check_roundtrip.py` compares a saved JSON file against a list of the fields the live state has (dumped from the app via the test hook or a one-liner) and prints every field present in the state but missing from the file: ``` python /check_roundtrip.py saved.json state_fields.json ``` Details and how to produce `state_fields.json` in [js.md](./js.md#completeness) and [python.md](./python.md#completeness). ## Pitfalls - `JSON.stringify(state)` with live objects: cyclic structure error, or megabytes of mesh data - that's the signal to split data from runtime (rule 2). - Loading replaces the whole state; clear selection/undo history or they point at ids that no longer exist. - Nested groups/children: recursion in both directions, and ids must stay unique after load (`nextId = max(ids) + 1`). - Floats: don't round on save; round only for display. - Binary data (images, STL buffers): base64 in the JSON only if small; otherwise a zip or separate files, with file names in the JSON. - Don't let a load failure wipe the current state: parse and validate first, then swap.