Skip to content

Commit d64b4a8

Browse files
KyleAMathewsclaudeobeattiekevin-dp
authored
fix: handle Temporal objects correctly in proxy deepClone and deepEqual (#434)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Oliver Beattie <oliver@obeattie.com> Co-authored-by: Kevin De Porre <kevin@electric-sql.com>
1 parent ea9113a commit d64b4a8

File tree

10 files changed

+613
-139
lines changed

10 files changed

+613
-139
lines changed

.changeset/thick-singers-appear.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Fix handling of Temporal objects in proxy's deepClone and deepEqual functions
6+
7+
- Temporal objects (like Temporal.ZonedDateTime) are now properly preserved during cloning instead of being converted to empty objects
8+
- Added detection for all Temporal API object types via Symbol.toStringTag
9+
- Temporal objects are returned directly from deepClone since they're immutable
10+
- Added proper equality checking for Temporal objects using their built-in equals() method
11+
- Prevents unnecessary proxy creation for immutable Temporal objects

packages/db/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
},
99
"devDependencies": {
1010
"@vitest/coverage-istanbul": "^3.0.9",
11-
"arktype": "^2.1.20"
11+
"arktype": "^2.1.20",
12+
"temporal-polyfill": "^0.3.0"
1213
},
1314
"exports": {
1415
".": {

packages/db/src/collection.ts

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { withArrayChangeTracking, withChangeTracking } from "./proxy"
2+
import { deepEquals } from "./utils"
23
import { SortedMap } from "./SortedMap"
34
import {
45
createSingleRowRefProxy,
@@ -1448,7 +1449,7 @@ export class CollectionImpl<
14481449
const isRedundantSync =
14491450
completedOp &&
14501451
newVisibleValue !== undefined &&
1451-
this.deepEqual(completedOp.value, newVisibleValue)
1452+
deepEquals(completedOp.value, newVisibleValue)
14521453

14531454
if (!isRedundantSync) {
14541455
if (
@@ -1472,7 +1473,7 @@ export class CollectionImpl<
14721473
} else if (
14731474
previousVisibleValue !== undefined &&
14741475
newVisibleValue !== undefined &&
1475-
!this.deepEqual(previousVisibleValue, newVisibleValue)
1476+
!deepEquals(previousVisibleValue, newVisibleValue)
14761477
) {
14771478
events.push({
14781479
type: `update`,
@@ -1715,29 +1716,6 @@ export class CollectionImpl<
17151716
}
17161717
}
17171718

1718-
private deepEqual(a: any, b: any): boolean {
1719-
if (a === b) return true
1720-
if (a == null || b == null) return false
1721-
if (typeof a !== typeof b) return false
1722-
1723-
if (typeof a === `object`) {
1724-
if (Array.isArray(a) !== Array.isArray(b)) return false
1725-
1726-
const keysA = Object.keys(a)
1727-
const keysB = Object.keys(b)
1728-
if (keysA.length !== keysB.length) return false
1729-
1730-
const keysBSet = new Set(keysB)
1731-
for (const key of keysA) {
1732-
if (!keysBSet.has(key)) return false
1733-
if (!this.deepEqual(a[key], b[key])) return false
1734-
}
1735-
return true
1736-
}
1737-
1738-
return false
1739-
}
1740-
17411719
public validateData(
17421720
data: unknown,
17431721
type: `insert` | `update`,

packages/db/src/proxy.ts

Lines changed: 16 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* and provides a way to retrieve those changes.
44
*/
55

6+
import { deepEquals, isTemporal } from "./utils"
7+
68
/**
79
* Simple debug utility that only logs when debug mode is enabled
810
* Set DEBUG to true in localStorage to enable debug logging
@@ -133,6 +135,13 @@ function deepClone<T extends unknown>(
133135
return clone as unknown as T
134136
}
135137

138+
// Handle Temporal objects
139+
if (isTemporal(obj)) {
140+
// Temporal objects are immutable, so we can return them directly
141+
// This preserves all their internal state correctly
142+
return obj
143+
}
144+
136145
const clone = {} as Record<string | symbol, unknown>
137146
visited.set(obj as object, clone)
138147

@@ -156,107 +165,6 @@ function deepClone<T extends unknown>(
156165
return clone as T
157166
}
158167

159-
/**
160-
* Deep equality check that handles special types like Date, RegExp, Map, and Set
161-
*/
162-
function deepEqual<T>(a: T, b: T): boolean {
163-
// Handle primitive types
164-
if (a === b) return true
165-
166-
// If either is null or not an object, they're not equal
167-
if (
168-
a === null ||
169-
b === null ||
170-
typeof a !== `object` ||
171-
typeof b !== `object`
172-
) {
173-
return false
174-
}
175-
176-
// Handle Date objects
177-
if (a instanceof Date && b instanceof Date) {
178-
return a.getTime() === b.getTime()
179-
}
180-
181-
// Handle RegExp objects
182-
if (a instanceof RegExp && b instanceof RegExp) {
183-
return a.source === b.source && a.flags === b.flags
184-
}
185-
186-
// Handle Map objects
187-
if (a instanceof Map && b instanceof Map) {
188-
if (a.size !== b.size) return false
189-
190-
const entries = Array.from(a.entries())
191-
for (const [key, val] of entries) {
192-
if (!b.has(key) || !deepEqual(val, b.get(key))) {
193-
return false
194-
}
195-
}
196-
197-
return true
198-
}
199-
200-
// Handle Set objects
201-
if (a instanceof Set && b instanceof Set) {
202-
if (a.size !== b.size) return false
203-
204-
// Convert to arrays for comparison
205-
const aValues = Array.from(a)
206-
const bValues = Array.from(b)
207-
208-
// Simple comparison for primitive values
209-
if (aValues.every((val) => typeof val !== `object`)) {
210-
return aValues.every((val) => b.has(val))
211-
}
212-
213-
// For objects in sets, we need to do a more complex comparison
214-
// This is a simplified approach and may not work for all cases
215-
return aValues.length === bValues.length
216-
}
217-
218-
// Handle arrays
219-
if (Array.isArray(a) && Array.isArray(b)) {
220-
if (a.length !== b.length) return false
221-
222-
for (let i = 0; i < a.length; i++) {
223-
if (!deepEqual(a[i], b[i])) return false
224-
}
225-
226-
return true
227-
}
228-
229-
// Handle TypedArrays
230-
if (
231-
ArrayBuffer.isView(a) &&
232-
ArrayBuffer.isView(b) &&
233-
!(a instanceof DataView) &&
234-
!(b instanceof DataView)
235-
) {
236-
const typedA = a as unknown as TypedArray
237-
const typedB = b as unknown as TypedArray
238-
if (typedA.length !== typedB.length) return false
239-
240-
for (let i = 0; i < typedA.length; i++) {
241-
if (typedA[i] !== typedB[i]) return false
242-
}
243-
244-
return true
245-
}
246-
247-
// Handle plain objects
248-
const keysA = Object.keys(a as object)
249-
const keysB = Object.keys(b as object)
250-
251-
if (keysA.length !== keysB.length) return false
252-
253-
return keysA.every(
254-
(key) =>
255-
Object.prototype.hasOwnProperty.call(b, key) &&
256-
deepEqual((a as any)[key], (b as any)[key])
257-
)
258-
}
259-
260168
let count = 0
261169
function getProxyCount() {
262170
count += 1
@@ -392,7 +300,7 @@ export function createChangeProxy<
392300
)
393301

394302
// If the value is not equal to original, something is still changed
395-
if (!deepEqual(currentValue, originalValue)) {
303+
if (!deepEquals(currentValue, originalValue)) {
396304
debugLog(`Property ${String(prop)} is different, returning false`)
397305
return false
398306
}
@@ -411,7 +319,7 @@ export function createChangeProxy<
411319
const originalValue = (state.originalObject as any)[sym]
412320

413321
// If the value is not equal to original, something is still changed
414-
if (!deepEqual(currentValue, originalValue)) {
322+
if (!deepEquals(currentValue, originalValue)) {
415323
debugLog(`Symbol property is different, returning false`)
416324
return false
417325
}
@@ -741,12 +649,13 @@ export function createChangeProxy<
741649
return value.bind(ptarget)
742650
}
743651

744-
// If the value is an object, create a proxy for it
652+
// If the value is an object (but not Date, RegExp, or Temporal), create a proxy for it
745653
if (
746654
value &&
747655
typeof value === `object` &&
748656
!((value as any) instanceof Date) &&
749-
!((value as any) instanceof RegExp)
657+
!((value as any) instanceof RegExp) &&
658+
!isTemporal(value)
750659
) {
751660
// Create a parent reference for the nested object
752661
const nestedParent = {
@@ -779,11 +688,11 @@ export function createChangeProxy<
779688
)
780689

781690
// Only track the change if the value is actually different
782-
if (!deepEqual(currentValue, value)) {
691+
if (!deepEquals(currentValue, value)) {
783692
// Check if the new value is equal to the original value
784693
// Important: Use the originalObject to get the true original value
785694
const originalValue = changeTracker.originalObject[prop as keyof T]
786-
const isRevertToOriginal = deepEqual(value, originalValue)
695+
const isRevertToOriginal = deepEquals(value, originalValue)
787696
debugLog(
788697
`value:`,
789698
value,

0 commit comments

Comments
 (0)