Skip to content

Conversation

KyleAMathews
Copy link
Collaborator

Summary

  • Fixes an issue where Temporal objects (like Temporal.ZonedDateTime) were being converted to empty objects during proxy tracking operations
  • Adds proper support for all Temporal API objects in the proxy's deepClone and deepEqual functions
  • Prevents unnecessary proxy creation for immutable Temporal objects

Problem

When using Temporal objects with TanStack DB's proxy system, the deepClone function was attempting to clone Temporal objects as regular objects by copying their enumerable properties. Since Temporal objects store their data internally (not as enumerable properties), this resulted in empty objects being produced.

Solution

This PR adds special handling for Temporal objects:

  1. In deepClone: Detects Temporal objects by their Symbol.toStringTag and returns them directly (since they're immutable)
  2. In deepEqual: Adds proper equality checking using Temporal's built-in equals() method where available
  3. In proxy get handler: Prevents creating proxies for Temporal objects since they're immutable

Test plan

  • Added comprehensive test coverage for Temporal.ZonedDateTime, Temporal.PlainDate, and Temporal.Duration
  • All existing proxy tests pass
  • Package builds successfully

🤖 Generated with Claude Code

Copy link

changeset-bot bot commented Aug 22, 2025

🦋 Changeset detected

Latest commit: 1aed4aa

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 8 packages
Name Type
@tanstack/db Patch
@tanstack/electric-db-collection Patch
@tanstack/query-db-collection Patch
@tanstack/react-db Patch
@tanstack/solid-db Patch
@tanstack/svelte-db Patch
@tanstack/trailbase-db-collection Patch
@tanstack/vue-db Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link

pkg-pr-new bot commented Aug 22, 2025

More templates

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@434

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@434

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@434

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@434

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@434

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@434

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@434

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@434

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@434

commit: 1aed4aa

Copy link
Contributor

github-actions bot commented Aug 22, 2025

Size Change: +74 B (+0.11%)

Total Size: 64.6 kB

Filename Size Change
./packages/db/dist/esm/collection.js 10.4 kB -128 B (-1.21%)
./packages/db/dist/esm/proxy.js 3.87 kB -322 B (-7.69%)
./packages/db/dist/esm/utils.js 943 B +524 B (+125.06%) 🆘
ℹ️ View Unchanged
Filename Size
./packages/db/dist/esm/change-events.js 1.13 kB
./packages/db/dist/esm/deferred.js 230 B
./packages/db/dist/esm/errors.js 3 kB
./packages/db/dist/esm/index.js 1.52 kB
./packages/db/dist/esm/indexes/auto-index.js 745 B
./packages/db/dist/esm/indexes/base-index.js 605 B
./packages/db/dist/esm/indexes/btree-index.js 1.74 kB
./packages/db/dist/esm/indexes/lazy-index.js 1.25 kB
./packages/db/dist/esm/local-only.js 827 B
./packages/db/dist/esm/local-storage.js 2.03 kB
./packages/db/dist/esm/optimistic-action.js 294 B
./packages/db/dist/esm/query/builder/functions.js 575 B
./packages/db/dist/esm/query/builder/index.js 3.79 kB
./packages/db/dist/esm/query/builder/ref-proxy.js 890 B
./packages/db/dist/esm/query/compiler/evaluators.js 1.48 kB
./packages/db/dist/esm/query/compiler/expressions.js 631 B
./packages/db/dist/esm/query/compiler/group-by.js 2.06 kB
./packages/db/dist/esm/query/compiler/index.js 2.15 kB
./packages/db/dist/esm/query/compiler/joins.js 2.36 kB
./packages/db/dist/esm/query/compiler/order-by.js 1.17 kB
./packages/db/dist/esm/query/compiler/select.js 655 B
./packages/db/dist/esm/query/ir.js 466 B
./packages/db/dist/esm/query/live-query-collection.js 333 B
./packages/db/dist/esm/query/live/collection-config-builder.js 2.56 kB
./packages/db/dist/esm/query/live/collection-subscriber.js 2.4 kB
./packages/db/dist/esm/query/optimizer.js 2.63 kB
./packages/db/dist/esm/SortedMap.js 1.24 kB
./packages/db/dist/esm/transactions.js 2.29 kB
./packages/db/dist/esm/utils/btree.js 6.02 kB
./packages/db/dist/esm/utils/comparison.js 718 B
./packages/db/dist/esm/utils/index-optimization.js 1.62 kB

compressed-size-action::db-package-size

Copy link
Contributor

github-actions bot commented Aug 22, 2025

Size Change: 0 B

Total Size: 1.16 kB

ℹ️ View Unchanged
Filename Size
./packages/react-db/dist/esm/index.js 152 B
./packages/react-db/dist/esm/useLiveQuery.js 1.01 kB

compressed-size-action::react-db-package-size

@obeattie
Copy link
Contributor

This works beautifully. Thanks v much for jumping on this so quickly 🏎️

@obeattie
Copy link
Contributor

Actually, I spoke too soon 😓 Unless I'm doing something wrong, I'm seeing some different odd behaviour now, and I put together this little reproduction.

If you click the button after the page loads to mutate the data in the store, you will see that the data that's returned from the live query gets out of sync with the data retrieved directly from the store, even though a refetch is happening.

@KyleAMathews
Copy link
Collaborator Author

Ok interesting — thanks for the further reproduction! Me or someone else will look into this next week.

@obeattie
Copy link
Contributor

Thanks, I appreciate it. Looks like the code in collection.ts to check deep equality is the culprit – it will need some logic similar to what you added in proxy.ts. I just copy-pasted it across and the problem goes away.

private deepEqual(a: any, b: any): boolean {
if (a === b) return true
if (a == null || b == null) return false
if (typeof a !== typeof b) return false
if (typeof a === `object`) {
if (Array.isArray(a) !== Array.isArray(b)) return false
const keysA = Object.keys(a)
const keysB = Object.keys(b)
if (keysA.length !== keysB.length) return false
const keysBSet = new Set(keysB)
for (const key of keysA) {
if (!keysBSet.has(key)) return false
if (!this.deepEqual(a[key], b[key])) return false
}
return true
}
return false
}

@KyleAMathews
Copy link
Collaborator Author

Ah nice!

@obeattie
Copy link
Contributor

I took a stab at fixing this, and here's a commit you can cherry-pick onto this branch (if you agree with it!) that makes the issues I was seeing go away: obeattie@c7cf39c

I noticed there are a bunch of different functions for checking deep equality, so I combined them in that commit.

Copy link
Contributor

@kevin-dp kevin-dp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a look at this PR and left some comments that need fixing. Nothing major, just refactorings to avoid code duplication. Still need to look into @obeattie's commit.

`Temporal.PlainYearMonth`,
`Temporal.PlainMonthDay`,
`Temporal.Duration`,
`Temporal.TimeZone`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 2 temporals (TimeZone and Calendar) don't seem to exist according to the docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal

@@ -133,6 +133,30 @@ function deepClone<T extends unknown>(
return clone as unknown as T
}

// Handle Temporal objects
// Check if it's a Temporal object by checking for the Temporal brand
if (typeof (obj as any)[Symbol.toStringTag] === `string`) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should extract the check to a helper function isTemporalObject that does this logic of checking the toStringTag. We could also use a plain instanceof check unless these temporals may come from a different realm (not sure?).

const aTag = (a as any)[Symbol.toStringTag]
const bTag = (b as any)[Symbol.toStringTag]

if (typeof aTag === `string` && typeof bTag === `string`) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not duplicate this temporal checking logic. We should use a helper function.


if (aIsTemporal && bIsTemporal) {
// If they're different Temporal types, they're not equal
if (aTag !== bTag) return false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also make a helper function to get the tag of a temporal

}

// For Duration, use toString comparison as it doesn't have equals
if (aTag === `Temporal.Duration`) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the default is to fallback to toString comparison (L308) there's no need for this if test.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, i wouldn't fallback to string comparison here but rather use the static compare method: Temporal.Duration.compare(a, b) === 0

@@ -741,12 +806,31 @@ export function createChangeProxy<
return value.bind(ptarget)
}

// If the value is an object, create a proxy for it
// Check if it's a Temporal object - don't proxy them as they're immutable
const isTemporalObject =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a helper function instead of duplicating this code.

@kevin-dp
Copy link
Contributor

kevin-dp commented Aug 28, 2025

@obeattie Your commit is spot on! As far as i can tell, you replaced the different versions of deepEquals spread across the code base by a single deepEquals version that handles Temporals properly and that fixes your issue, right?

Thanks for jumping in and fixing this 👍

@kevin-dp
Copy link
Contributor

@obeattie I cherry-picked your commit and i further cleaned this PR. Your stackblitz example now works correctly. Before i merge this I'd like to add a unit test though that reproduces the original bug to avoid future regressions.

@obeattie
Copy link
Contributor

Thanks for picking this up @kevin-dp!

As far as i can tell, you replaced the different versions of deepEquals spread across the code base by a single deepEquals version that handles Temporals properly and that fixes your issue, right?

Yes that's exactly right. In the longer term, and as I think is touched upon in #428's discussion, I think it might be good to allow users to provide their own comparators for different types. If that functionality existed I would probably have just used that, though it's also easy to make a case that Dates and Temporal objects ought to work out of the box.

Appreciate you both for fixing this so quickly!

KyleAMathews and others added 5 commits August 28, 2025 11:03
The proxy's deepClone function was attempting to clone Temporal objects
(like Temporal.ZonedDateTime) as regular objects, resulting in empty
objects being produced. This fix:

- Detects Temporal objects by their Symbol.toStringTag
- Returns Temporal objects directly from deepClone (they're immutable)
- Adds proper equality checking for Temporal objects in deepEqual
- Prevents proxy creation for Temporal objects in the get handler
- Adds test coverage for Temporal.ZonedDateTime, PlainDate, and Duration

Fixes the issue where Temporal objects were being converted to empty
objects during proxy tracking operations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
- Consolidate multiple deepEquals implementations into a single function in utils.ts
- Remove deepEqual functions from collection.ts and proxy.ts
- Add tests
@kevin-dp
Copy link
Contributor

I added a unit test that reproduces the bug when ran before @obeattie's commit and passes after that commit. All good on my side to merge it. I'll wait untill this afternoon to give @KyleAMathews a chance to have a look at it.

@KyleAMathews
Copy link
Collaborator Author

Looks great! I found another bug around live queries not updating when I modified a (regular) date which this I tested this PR on and it also fixed so glad you cleaned up our sprawling deep equals mess @obeattie :-D Let's get this out there

@KyleAMathews KyleAMathews merged commit d64b4a8 into main Aug 28, 2025
6 checks passed
@KyleAMathews KyleAMathews deleted the fix-temporal branch August 28, 2025 13:02
@github-actions github-actions bot mentioned this pull request Aug 28, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants