jcode/skills/save-load-json/js.md

5.4 KiB

Save / load in a browser app (ES module)

State layout

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

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:

// 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

$('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)

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:

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 <skill-dir>/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.