update()'s draft is built via a deepClone() that only preserves the prototype for a fixed allowlist of types (Date, RegExp, Array, typed arrays, Map, Set, Temporal.*). Any other class instance is cloned into a plain {} with the same properties but no prototype.
A field the callback assigns directly is fine (the value is stored as-is, bypassing deepClone). But any sibling field the callback doesn't touch is silently replaced by a prototype-less clone, so editing one field on a row corrupts every other field which contains a custom class instance, even though nothing about it changed.
This is easy to miss (the corrupted value still looks more or less right in a debugger, since the shape matches) and tends to surface later, e.g. as an instanceof check failing once the value is serialized and re-validated. That's how I found it: a zod z.custom(v => v instanceof MyClass) check started failing on partial updates, for a field the update never touched.
update()'s draft is documented as "Immer-style," and this is a divergence from Immer's actual behavior for such values: un-marked values (any class instance without immerable) are simply not drafted. Immer leaves them alone, by reference, untouched.
Reproduction
import { createCollection, localOnlyCollectionOptions } from "@tanstack/db";
class Money {
constructor(public cents: number) {}
}
const collection = createCollection(
localOnlyCollectionOptions({
getKey: (item: { id: string; price: Money; name: string }) => item.id,
initialData: [{ id: "x", price: new Money(500), name: "Widget" }],
})
);
collection.update("x", (draft) => {
draft.name = "Gadget"; // doesn't touch `price`
});
const after = collection.get("x")!;
console.log(after.price instanceof Money); // false — expected true
console.log(after.price); // { cents: 500 } — prototype gone
Expected vs actual
Expected: after.price is unchanged and still instanceof Money.
Actual: after.price is a plain object with matching properties but no prototype.
Impact
Any collection with a custom class instance as a field value — a value object, a domain type, anything outside the built-in allowlist — silently loses that value's type on any update() that doesn't happen to also reassign it. In my case this broke partial edits to a row with multiple class-instance fields, and only surfaced as a downstream validation error, not at the point of corruption.
update()'s draft is built via adeepClone()that only preserves the prototype for a fixed allowlist of types (Date,RegExp,Array, typed arrays,Map,Set,Temporal.*). Any other class instance is cloned into a plain{}with the same properties but no prototype.A field the callback assigns directly is fine (the value is stored as-is, bypassing
deepClone). But any sibling field the callback doesn't touch is silently replaced by a prototype-less clone, so editing one field on a row corrupts every other field which contains a custom class instance, even though nothing about it changed.This is easy to miss (the corrupted value still looks more or less right in a debugger, since the shape matches) and tends to surface later, e.g. as an
instanceofcheck failing once the value is serialized and re-validated. That's how I found it: a zodz.custom(v => v instanceof MyClass)check started failing on partial updates, for a field the update never touched.update()'s draft is documented as "Immer-style," and this is a divergence from Immer's actual behavior for such values: un-marked values (any class instance withoutimmerable) are simply not drafted. Immer leaves them alone, by reference, untouched.Reproduction
Expected vs actual
Expected:
after.priceis unchanged and stillinstanceof Money.Actual:
after.priceis a plain object with matching properties but no prototype.Impact
Any collection with a custom class instance as a field value — a value object, a domain type, anything outside the built-in allowlist — silently loses that value's type on any
update()that doesn't happen to also reassign it. In my case this broke partial edits to a row with multiple class-instance fields, and only surfaced as a downstream validation error, not at the point of corruption.