Skip to content

[critical] deepClone silently converts Date (and any class instance without .isEqual) into {} — corrupted data written to Firestore #41

Description

@TrystonPerry

Severity: Critical — silent data corruption on an idiomatic input

Where

src/utils/diff.ts:363-381 (deepClone), gated by isFirestoreOpaque at src/utils/diff.ts:34-43:

export const deepClone = <T>(value: T): T => {
    if (value === null || typeof value !== 'object') return value
    if (isFirestoreOpaque(value)) return value        // requires an .isEqual method
    if (Array.isArray(value)) return value.map(deepClone) as T
    const result: Record<string, unknown> = {}
    for (const key of Object.keys(value)) {           // Object.keys(new Date()) === []
        result[key] = deepClone((value as Record<string, unknown>)[key])
    }
    return result as T                                // → {}
}

isFirestoreOpaque only recognizes objects exposing an .isEqual method (Timestamp, DocumentReference, GeoPoint, Bytes, FieldValue sentinels). A native Date has no .isEqual and its prototype is not Object.prototype, so it is neither opaque nor plain — it falls through to the key walk, and since Date has no own enumerable keys, the clone is {}.

Failure mode

Any Date-valued field in a write payload is replaced by an empty map {}, and that {} is what lands in Firestore. It writes cleanly (an empty map is a legal value), so nothing crashes — the data is just silently wrong.

Trigger — end-to-end path

  • document.set(data) stores state.localState = deepClone(data) (src/core/document.ts:385), and sync() writes setDoc(docRef, state.localState) (src/core/document.ts:502).
  • The update path clones its mutation base at src/core/document.ts:292; collection paths clone at src/core/collection.ts:287/379/422/482.

Firestore's own SDK accepts native Date (converting it to Timestamp server-side), and a Zod schema field of z.date() invites exactly this input, so set({ createdAt: new Date() }) is idiomatic — and currently writes { createdAt: {} }.

The same defect hits any class instance lacking .isEqual (Map, Set, custom classes), but Date is the realistic, Firestore-supported case.

Verified

Dynamically confirmed against master with vitest:

deepClone({ createdAt: new Date('2026-01-01'), n: 1 })
// → { "createdAt": {}, "n": 1 }   (instanceof Date: false, keys: 0)

Suggested fix direction

Handle Date explicitly in deepClone (return new Date(value.getTime()) or pass through by reference — Dates are treated as immutable everywhere else in the pipeline). Audit the sibling helpers (isDeepEqual, valuesEqualForNoOp, computeDiff) for the same blind spot: two Dates currently never compare equal there either (not Object.is-identical, not opaque, not plain objects), which means a Date field also produces spurious diffs on every sync even once the clone bug is fixed.


Filed by the Critical Review agent (automated deep scan). Finding was verified by hand against the cited lines and reproduced dynamically before filing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions