# Save / load in a browser app (ES module) ## State layout ```js const state = { version: 1, settings: { grid: 1, units: 'mm' }, objects: [], // data records only: {id, type, params, position, rotation, scale, color, hole, children} nextId: 1, selection: new Set(), // runtime: not saved }; const runtime = new Map(); // id -> { mesh, ... } (never inside the records) const RUNTIME = ['selection']; // top-level fields that are NOT saved ``` ## toData / load - save everything except RUNTIME ```js function toData() { const data = { ...state }; for (const k of RUNTIME) delete data[k]; assertPlain(data); // on the LIVE data: meshes, cycles, class instances -> error now, not a broken file return { version: state.version, app: 'myapp', state: structuredClone(data) }; } function assertPlain(v, path = 'state', seen = new Set()) { if (v === null || typeof v !== 'object') { if (typeof v === 'number' && !Number.isFinite(v)) throw new Error(`${path} is ${v} - JSON has no NaN/Infinity`); return; } if (seen.has(v)) throw new Error(`${path} is cyclic - a runtime object leaked into the data (add it to RUNTIME or the runtime map)`); seen.add(v); const proto = Object.getPrototypeOf(v); if (proto !== Object.prototype && proto !== Array.prototype && proto !== null) throw new Error(`${path} is a ${v.constructor?.name ?? 'class instance'} - store plain data, keep the object in the runtime map`); for (const k of Object.keys(v)) assertPlain(v[k], `${path}.${k}`, seen); } function fromRecord(rec) { // the ONE constructor path (add, paste, load) const obj = { children: [], ...rec }; // defaults first, record wins runtime.set(obj.id, { mesh: buildMesh(obj) }); obj.children.forEach(fromRecord); return obj; } function load(data) { data = migrate(data); // old versions -> current validate(data); // throws -> current state untouched clearRuntime(); Object.assign(state, data.state); // replaces objects, settings, nextId, ... state.selection = new Set(); // re-create runtime fields state.objects = state.objects.map(fromRecord); state.nextId = Math.max(state.nextId ?? 1, ...allIds(state.objects).map(i => i + 1)); // never reuse an id render(); } const allIds = objs => objs.flatMap(o => [o.id, ...allIds(o.children ?? [])]); function migrate(data) { if (data.version === undefined) data = { version: 0, state: data }; // very old: bare state if (data.version < 1) { data.state.objects.forEach(o => { o.scale ??= [1, 1, 1]; }); data.version = 1; } return data; } function validate(data) { if (!data?.state || !Array.isArray(data.state.objects)) throw new Error('not a myapp file'); } ``` `assertPlain` is what turns "the JSON is silently wrong" into an error at save time: a three.js mesh, a cyclic parent link or a `Set` in the data record fails loudly with the path. `Map`/`Set`/typed arrays survive `structuredClone` but `JSON.stringify` turns them into `{}` - convert those explicitly: ```js // in toData(): data.tags = [...state.tags]; // Set -> array // in load(): state.tags = new Set(data.state.tags); // Float32Array -> Array.from(a); Date -> d.toISOString(); back: new Date(s) ``` ## Buttons - nothing but a call ```js $('save').addEventListener('click', () => download('scene.json', JSON.stringify(toData(), null, 2), 'application/json')); $('load').addEventListener('click', () => pickFile('.json', true, text => { try { load(JSON.parse(text)); } catch (e) { alert('Load failed: ' + e.message); } })); ``` (`download`/`pickFile` are in the single-file-webapp skill, patterns.md.) ## Round-trip test (browser-test skill, through the REAL buttons) ```js module.exports = async (h) => { await h.eval(() => { __app.add('box', { size: 2 }); __app.add('cylinder', { r: 0.5, h: 3 }); }); await h.eval(() => { const o = __app.state.objects[1]; o.position = [3, 0, 1]; o.hole = true; }); const before = await h.eval(() => JSON.stringify(__app.toData())); const f = await h.download('#save'); // real export path if (f.text !== JSON.stringify(JSON.parse(before), null, 2)) throw new Error('save button wrote something else than toData()'); require('fs').writeFileSync('fixtures/scene.json', f.text); await h.eval(() => __app.reset()); await h.upload('#load', 'fixtures/scene.json'); // real import path const after = await h.eval(() => JSON.stringify(__app.toData())); if (after !== before) throw new Error('round trip differs:\n' + before + '\n' + after); }; ``` Expose `toData`, `add`, `reset` on `window.__app`. ## Completeness Dump the field paths of the live state (runtime fields included) once: ```js const fields = await h.eval(() => { const out = new Set(); (function walk(v, p) { if (Array.isArray(v)) { v.forEach(x => walk(x, p + '[]')); return; } if (v && typeof v === 'object' && !(v instanceof Set) && !(v instanceof Map)) { for (const k of Object.keys(v)) walk(v[k], p ? p + '.' + k : k); return; } out.add(p); })(__app.state, ''); return [...out]; }); require('fs').writeFileSync('state_fields.json', JSON.stringify(fields, null, 2)); ``` Then `python /check_roundtrip.py fixtures/scene.json state_fields.json` lists every state field that the file lacks. Each one is either a RUNTIME field (add it to the list in the app, so the omission is explicit) or a bug.