From ad89a83e9be080c9ad2a2d119e28aff123399661 Mon Sep 17 00:00:00 2001 From: Oliver Beattie Date: Mon, 10 Aug 2026 11:21:58 +0200 Subject: [PATCH 1/3] test: reproduce custom class instance corruption --- packages/db/tests/collection.test.ts | 42 ++++++++++++++++++++++++++++ packages/db/tests/proxy.test.ts | 17 +++++++++++ 2 files changed, 59 insertions(+) diff --git a/packages/db/tests/collection.test.ts b/packages/db/tests/collection.test.ts index e96994db64..a12a0d565d 100644 --- a/packages/db/tests/collection.test.ts +++ b/packages/db/tests/collection.test.ts @@ -253,6 +253,48 @@ describe(`Collection`, () => { }).toThrow(KeyUpdateNotAllowedError) }) + it(`should preserve untouched custom class instances during updates`, async () => { + class Money { + constructor(public cents: number) {} + } + + type Product = { + id: string + details: { name: string; price: Money } + } + + const price = new Money(500) + const collection = createCollection({ + id: `custom-class-update-test`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ + type: `insert`, + value: { + id: `product-1`, + details: { name: `Widget`, price }, + }, + }) + commit() + markReady() + }, + }, + onUpdate: async () => {}, + }) + + await collection.stateWhenReady() + + collection.update(`product-1`, (draft) => { + draft.details.name = `Gadget` + }) + + const updated = collection.get(`product-1`) + expect(updated?.details.price).toBe(price) + expect(updated?.details.price).toBeInstanceOf(Money) + }) + it(`It shouldn't expose any state until the initial sync is finished`, () => { // Create a collection with a mock sync plugin createCollection<{ name: string }>({ diff --git a/packages/db/tests/proxy.test.ts b/packages/db/tests/proxy.test.ts index bbac0151af..8ff3ed11de 100644 --- a/packages/db/tests/proxy.test.ts +++ b/packages/db/tests/proxy.test.ts @@ -1370,6 +1370,23 @@ describe(`Proxy Library`, () => { age: 30, }) }) + + it(`should preserve untouched custom class instances in changed objects`, () => { + class Money { + constructor(public cents: number) {} + } + + const price = new Money(500) + const obj = { details: { name: `Widget`, price } } + + const changes = withChangeTracking(obj, (proxy) => { + proxy.details.name = `Gadget` + }) + + const changedDetails = changes.details as typeof obj.details + expect(changedDetails.price).toBe(price) + expect(changedDetails.price).toBeInstanceOf(Money) + }) }) describe(`withArrayChangeTracking`, () => { From 1d9d5cf41542d9b031924f95f2e4d1a649c49c41 Mon Sep 17 00:00:00 2001 From: Oliver Beattie Date: Mon, 10 Aug 2026 11:55:33 +0200 Subject: [PATCH 2/3] fix: preserve custom class instances during updates --- packages/db/src/proxy.ts | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/db/src/proxy.ts b/packages/db/src/proxy.ts index 57723e3cca..2cb5ee385b 100644 --- a/packages/db/src/proxy.ts +++ b/packages/db/src/proxy.ts @@ -54,18 +54,27 @@ const MAP_SET_ITERATOR_METHODS = new Set([ `forEach`, ]) +function isPlainObject(value: object): boolean { + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + /** - * Check if a value is a proxiable object (not Date, RegExp, or Temporal) + * Check if a value can be safely proxied without changing its semantics. */ function isProxiableObject( value: unknown, ): value is Record { + if (value === null || typeof value !== `object`) { + return false + } + return ( - value !== null && - typeof value === `object` && - !((value as any) instanceof Date) && - !((value as any) instanceof RegExp) && - !isTemporal(value) + isPlainObject(value) || + Array.isArray(value) || + value instanceof Map || + value instanceof Set || + (ArrayBuffer.isView(value) && !(value instanceof DataView)) ) } @@ -589,7 +598,16 @@ function deepClone( return obj } - const clone = {} as Record + // Preserve non-plain objects by reference. Proxying or cloning an arbitrary + // class instance as a plain object strips its prototype and internal state. + if (!isPlainObject(obj)) { + return obj + } + + const clone = Object.create(Object.getPrototypeOf(obj)) as Record< + string | symbol, + unknown + > visited.set(obj as object, clone) for (const key in obj) { @@ -897,7 +915,7 @@ export function createChangeProxy< return value.bind(ptarget) } - // If the value is an object (but not Date, RegExp, or Temporal), create a proxy for it + // Proxy only values whose semantics are preserved by our draft handling. if (isProxiableObject(value)) { // Create a parent reference for the nested object const nestedParent = { From 0e5af36a91071fc47b18bec83f33896b92b66fa5 Mon Sep 17 00:00:00 2001 From: Oliver Beattie Date: Mon, 10 Aug 2026 12:00:42 +0200 Subject: [PATCH 3/3] add changeset --- .changeset/slow-phones-fix.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/slow-phones-fix.md diff --git a/.changeset/slow-phones-fix.md b/.changeset/slow-phones-fix.md new file mode 100644 index 0000000000..83cf3de159 --- /dev/null +++ b/.changeset/slow-phones-fix.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Fix update() replacing untouched custom class instances with plain objects.