Skip to content

IndexedDB persistence crashes with DataCloneError when persisting a deleted (symbolDelete) value #658

Description

@LinusOssmann

Summary

When a record is deleted, Legend-State marks it internally with symbolDelete (Symbol('delete')). If the observable is persisted with the IndexedDB plugin, this Symbol reaches IDBObjectStore.put(), which uses the Structured Clone Algorithm and cannot serialise Symbol values. The result is an unrecoverable runtime crash on every persisted deletion:

DataCloneError: Failed to execute 'put' on 'IDBObjectStore': Symbol(delete) could not be cloned.

This makes IndexedDB persistence unusable for any app that deletes records.

Root cause

_setItem in src/persist-plugins/indexeddb.ts guards deletion with a falsy check only:

_setItem(table, key, value, store, config) {
  if (!value) {
    // ...delete from store
    return store.delete(key);
  } else {
    // ...
    return store.put(value); // <- symbolDelete lands here
  }
}

symbolDelete is truthy (!symbolDelete === false), so it falls through to store.put(symbolDelete) and throws.

Two call paths reach this with a Symbol value:

  • Item-level deletionpath: ['itemId'], valueAtPath: symbolDelete.
  • Full-table replacement (mode: 'set') — path: [], valueAtPath: { id1: {…}, id2: symbolDelete }. _setTable delegates each entry to _setItem, so id2 crashes.

Reproduction

Any observable persisted with observablePersistIndexedDB where an item is deleted:

  1. Persist an object map to IndexedDB and let it load.
  2. Delete one of the entries (which internally sets symbolDelete on it).
  3. On the next persist flush, store.put() throws DataCloneError.

Deterministic minimal repro via the plugin directly:

const plugin = observablePersistIndexedDB({ databaseName, version, tableNames });
await plugin.initialize({ indexedDB: { databaseName, version, tableNames } });

// seed
await plugin.set('items', [{ path: ['id-1'], pathTypes: ['object'], valueAtPath: { id: 'id-1', name: 'Alice' } }], config);

// delete -> throws DataCloneError today
await plugin.set('items', [{ path: ['id-1'], pathTypes: ['object'], valueAtPath: symbolDelete }], config);

Proposed fix

Recognise Symbol values alongside the existing falsy check in _setItem:

-  if (!value) {
+  if (!value || typeof value === 'symbol') {
       if (this.tableData[table]) {
           delete this.tableData[table][key];
       }
       return store.delete(key);
   }

Because _setTable delegates to _setItem, this covers both the item-level and full-table paths.

Note on other persist plugins

The MMKV and AsyncStorage plugins don't crash, but have a related latent gap: their serializers (safeStringify / JSON.stringify) silently drop Symbol values, so a deleted item is written back as an empty object instead of being removed. Worth a follow-up, but out of scope for this issue.


I already have a local branch with the one-line fix plus two regression tests (item-level deletion and full-table replacement — both fail with DataCloneError before the fix, pass after). Happy to open a PR once this is triaged.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions