Skip to content

Nested optimistic updates strip prototypes from untouched sibling values #2349

Open

Description

@KyleAMathews

Editing a nested field can strip the prototype from an untouched sibling value in the same nested object. The optimistic row then has a different runtime type until sync confirms the row.

Reproduction

import { createCollection, type SyncConfig } from '@tanstack/db'

class Point {
  constructor(public value: number) {}
}

type Row = {
  id: number
  payload: { count: number; leaf: Point }
}

let sync!: Parameters<SyncConfig<Row, number>['sync']>[0]
let confirm!: () => void
const pending = new Promise<void>((resolve) => { confirm = resolve })
const row = (count: number): Row => ({
  id: 1,
  payload: { count, leaf: new Point(1) },
})

const collection = createCollection<Row, number>({
  getKey: (value) => value.id,
  startSync: true,
  gcTime: Infinity,
  sync: {
    sync(controls) {
      sync = controls
      controls.begin()
      controls.write({ type: 'insert', value: row(0) })
      controls.commit()
      controls.markReady()
    },
  },
  onUpdate: () => pending,
})

const isPoint = () => collection.get(1)!.payload.leaf instanceof Point
console.log(isPoint()) // true

const transaction = collection.update(1, (draft) => {
  draft.payload.count = 1 // Never touches leaf.
})
console.log(isPoint()) // false: leaf is now the plain object { value: 1 }.

sync.begin()
sync.write({ type: 'update', value: row(1) })
sync.commit()
confirm()
await transaction.when('settled')
console.log(isPoint()) // true
await collection.cleanup()

Expected behavior

Updating payload.count should preserve the runtime shape of untouched payload.leaf, including its class prototype. Consumers should not temporarily lose instanceof, inherited methods, or prototype-based validation merely because a sibling field was edited.

Null-prototype records should likewise remain null-prototype records when an unrelated sibling is edited. The fix must also retain draft isolation: preserving a prototype must not allow writes through a draft to mutate the stored original.

Observed behavior

A real Collection probe held onUpdate pending, performed the nested sibling write, supplied the corresponding sync confirmation, and awaited transaction settlement:

Original leaf Before edit Optimistic row After confirmation
new Point(1) Point.prototype Object.prototype Point.prototype
Object.assign(Object.create(null), { value: 1 }) null Object.prototype null
{ value: 1 } Object.prototype Object.prototype Object.prototype

All three cases emitted two change publications in that probe. This establishes prototype loss/restoration, not an additional-notification regression relative to the plain-object control.

Cause and scope

deepClone() in packages/db/src/proxy.ts reconstructs these nested values with {} while constructing the draft copy. Its special handling for arbitrary class instances applies when detaching changes, but not to this draft-copy path; null-prototype records also become {}.

This was reproduced in the development checkout while evaluating #2346. proxy.ts has no diff against origin/main at 1443dc92e5417394acbdc98b782caa9802848ce6; this PR did not introduce the cloning behavior. Its stricter Collection row comparison recognizes prototype changes, which makes restoring the original shape a meaningful row change. Track the draft-cloning repair separately from #2346.

Related: #1714 was closed after #1800 fixed its reported class-instance case. This reproduction exercises an untouched class instance inside the nested object whose sibling is edited. It should be included when validating that earlier fix's boundary.

Coverage needed

The draft coverage map already records original stored class instances being read as plain objects as an open limitation. Extend the existing draft oracle owner with generated nested sibling edits, class/plain/null-prototype values, and pending/confirmed observations. Require untouched values to retain their runtime shape, while checking mutation isolation and existing native-value behavior. A fix should show RED on the current implementation and GREEN after repair.

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