From dec16d22f009fadec657ccc1b23295660514bcff Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 30 Mar 2026 14:57:44 +0200 Subject: [PATCH 1/3] fix: default getKey on live query collections fails on enriched objects The default WeakMap-based getKey for live query collections breaks when enriched change values (with virtual props) are passed through chained collections. The enriched objects are new references not found in the WeakMap, causing all items to resolve to key `undefined` and collide. Fall back to `item.$key` (set by virtual props enrichment) when the WeakMap lookup misses. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../query/live/collection-config-builder.ts | 3 +- .../tests/query/live-query-collection.test.ts | 122 ++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 20fb26f79..3a4e948e1 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -227,7 +227,8 @@ export class CollectionConfigBuilder< id: this.id, getKey: this.config.getKey || - ((item) => this.resultKeys.get(item) as string | number), + ((item: any) => + (this.resultKeys.get(item) ?? item.$key) as string | number), sync: this.getSyncConfig(), compare: this.compare, defaultStringCollation: this.compareOptions, diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index d56e5a715..7c85bf663 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -2585,4 +2585,126 @@ describe(`createLiveQueryCollection`, () => { await preloadPromise }) }) + + describe(`chained live query collections without custom getKey`, () => { + it(`should return all items when a live query collection without getKey is used as a source`, async () => { + // Create a live query collection with the default (internal) getKey + const filteredUsers = createLiveQueryCollection({ + id: `filtered-users`, + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + })), + }) + + // Use the live query collection as a source in another live query collection + const derived = createLiveQueryCollection({ + id: `derived-from-live-query`, + query: (q) => q.from({ u: filteredUsers }), + }) + + await derived.preload() + + // Should contain all active users (Alice and Bob), not just 1 + expect(derived.size).toBe(2) + }) + + it(`should return all items when a live query collection with a join and no getKey is used as a source`, async () => { + type Team = { + id: number + name: string + lead_id: number + } + + const teamsCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-teams`, + getKey: (team) => team.id, + initialData: [ + { id: 1, name: `Alpha`, lead_id: 1 }, + { id: 2, name: `Beta`, lead_id: 2 }, + { id: 3, name: `Gamma`, lead_id: 1 }, + ], + }), + ) + + // Join teams with users — no custom getKey + const teamsWithLeads = createLiveQueryCollection({ + id: `teams-with-leads`, + query: (q) => + q + .from({ team: teamsCollection }) + .join( + { user: usersCollection }, + ({ team, user }) => eq(team.lead_id, user.id), + ) + .select(({ team, user }) => ({ + teamName: team.name, + leadName: user.name, + })), + }) + + // Use the joined live query collection as a source + const derived = createLiveQueryCollection({ + id: `derived-from-join`, + query: (q) => q.from({ t: teamsWithLeads }), + }) + + await derived.preload() + + // Should contain all 3 joined rows, not just 1 + expect(derived.size).toBe(3) + expect( + derived.toArray.map((row) => stripVirtualProps(row)), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ teamName: `Alpha`, leadName: `Alice` }), + expect.objectContaining({ teamName: `Beta`, leadName: `Bob` }), + expect.objectContaining({ teamName: `Gamma`, leadName: `Alice` }), + ]), + ) + }) + + it(`should propagate updates through chained live query collections without custom getKey`, async () => { + // Intermediate live query collection — no custom getKey + const intermediate = createLiveQueryCollection({ + id: `update-intermediate`, + query: (q) => + q + .from({ user: usersCollection }) + .select(({ user }) => ({ + id: user.id, + name: user.name, + })), + }) + + // Derived from the intermediate + const derived = createLiveQueryCollection({ + id: `update-derived`, + query: (q) => q.from({ u: intermediate }), + }) + + await derived.preload() + + // Should have all 3 users from sampleUsers, not just 1 + expect(derived.size).toBe(3) + + // Sync a new user into the source collection + usersCollection.utils.begin() + usersCollection.utils.write({ + type: `insert`, + value: { id: 4, name: `Diana`, active: true }, + }) + usersCollection.utils.commit() + + await flushPromises() + + // The derived collection should see all 4 items + expect(derived.size).toBe(4) + }) + }) }) From 2f2ee219f86099286177c94b843b01d3884485a8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:59:12 +0000 Subject: [PATCH 2/3] ci: apply automated fixes --- .../tests/query/live-query-collection.test.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 7c85bf663..7179cd388 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -2638,9 +2638,8 @@ describe(`createLiveQueryCollection`, () => { query: (q) => q .from({ team: teamsCollection }) - .join( - { user: usersCollection }, - ({ team, user }) => eq(team.lead_id, user.id), + .join({ user: usersCollection }, ({ team, user }) => + eq(team.lead_id, user.id), ) .select(({ team, user }) => ({ teamName: team.name, @@ -2658,9 +2657,7 @@ describe(`createLiveQueryCollection`, () => { // Should contain all 3 joined rows, not just 1 expect(derived.size).toBe(3) - expect( - derived.toArray.map((row) => stripVirtualProps(row)), - ).toEqual( + expect(derived.toArray.map((row) => stripVirtualProps(row))).toEqual( expect.arrayContaining([ expect.objectContaining({ teamName: `Alpha`, leadName: `Alice` }), expect.objectContaining({ teamName: `Beta`, leadName: `Bob` }), @@ -2674,12 +2671,10 @@ describe(`createLiveQueryCollection`, () => { const intermediate = createLiveQueryCollection({ id: `update-intermediate`, query: (q) => - q - .from({ user: usersCollection }) - .select(({ user }) => ({ - id: user.id, - name: user.name, - })), + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + name: user.name, + })), }) // Derived from the intermediate From f2821a48acc8b96cc3a4fe8b9361d15d18596a9b Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 30 Mar 2026 15:07:15 +0200 Subject: [PATCH 3/3] chore: add changeset for default getKey fix Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/fix-live-query-default-getkey.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/fix-live-query-default-getkey.md diff --git a/.changeset/fix-live-query-default-getkey.md b/.changeset/fix-live-query-default-getkey.md new file mode 100644 index 000000000..6ff4c2536 --- /dev/null +++ b/.changeset/fix-live-query-default-getkey.md @@ -0,0 +1,7 @@ +--- +'@tanstack/db': patch +--- + +fix: default getKey on live query collections fails when used as a source in chained collections + +The default WeakMap-based getKey breaks when enriched change values (with virtual props like $synced, $origin, $key) are passed through chained live query collections. The enriched objects are new references not found in the WeakMap, causing all items to resolve to key `undefined` and collapse into a single item. Falls back to `item.$key` when the WeakMap lookup misses.