From 522150a72451ad4a6685435e76f6ccdb1068bbac Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 15 Sep 2026 09:46:15 -0600 Subject: [PATCH 1/4] fix(solid-db): reconcile live queries by collection key --- .../fix-solid-custom-key-reconciliation.md | 5 ++ packages/solid-db/src/useLiveQuery.ts | 4 +- packages/solid-db/tests/useLiveQuery.test.tsx | 89 +++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-solid-custom-key-reconciliation.md diff --git a/.changeset/fix-solid-custom-key-reconciliation.md b/.changeset/fix-solid-custom-key-reconciliation.md new file mode 100644 index 0000000000..956fd08b24 --- /dev/null +++ b/.changeset/fix-solid-custom-key-reconciliation.md @@ -0,0 +1,5 @@ +--- +'@tanstack/solid-db': patch +--- + +Keep custom-key rows distinct when live query updates change their rendered order. diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index cf8d84f43d..41c136be26 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -347,7 +347,9 @@ export function useLiveQuery( currentCollection: Collection, ) => { setData((prev) => - reconcile(Array.from(currentCollection.values()))(prev).filter(Boolean), + reconcile(Array.from(currentCollection.values()), { key: `$key` })( + prev, + ).filter(Boolean), ) } diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index 85f9144fc1..626e29c5ed 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -2284,6 +2284,95 @@ describe(`Query Collections`, () => { expect(keys).toEqual([`1`, `3`]) }) + it(`keeps custom-key rows distinct when an update changes rendered order`, async () => { + type CustomKeyItem = { + _id: string + name: string + } + + const initialItems: Array = [ + { _id: `bob1`, name: `Bob` }, + { _id: `kevin1`, name: `Kevin` }, + { _id: `stuart1`, name: `Stuart` }, + ] + const reference = new Map( + initialItems.map((item) => [item._id, { ...item }]), + ) + const expectedRows = () => + Array.from(reference.values()) + .sort( + (left, right) => + left.name.localeCompare(right.name) || + left._id.localeCompare(right._id), + ) + .map((item) => ({ + key: item._id, + text: `${item._id}:${item.name}`, + })) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `custom-key-rendered-reorder`, + getKey: (item) => item._id, + initialData: initialItems.map((item) => ({ ...item })), + }), + ) + const renderedKeys: Array = [] + + function TestComponent() { + const query = useLiveQuery((q) => + q + .from({ items: collection }) + .orderBy(({ items }) => items.name, `asc`), + ) + + return ( +
    + + {(item) => { + renderedKeys.push(item.$key) + return ( +
  1. + {item._id}:{item.name} +
  2. + ) + }} +
    +
+ ) + } + + const rendered = render(() => ) + const readRenderedRows = () => + Array.from(rendered.getByTestId(`custom-key-list`).children).map( + (element) => ({ + key: element.getAttribute(`data-row-key`), + text: element.textContent, + }), + ) + + await waitFor(() => { + expect(rendered.getByTestId(`custom-key-list`).dataset.ready).toBe( + `true`, + ) + expect(renderedKeys).toEqual([`bob1`, `kevin1`, `stuart1`]) + expect(readRenderedRows()).toEqual(expectedRows()) + }) + + const updatedItem = { _id: `stuart1`, name: `Alvin` } + reference.set(updatedItem._id, { ...updatedItem }) + collection.utils.begin() + collection.utils.write({ type: `update`, value: updatedItem }) + collection.utils.commit() + + await waitFor(() => { + expect(collection.get(`stuart1`)?.name).toBe(`Alvin`) + expect(readRenderedRows()).toEqual(expectedRows()) + }) + }) + it(`should reflect optimistic inserts in the data array and reconcile after sync`, async () => { const collection = createCollection( mockSyncCollectionOptions({ From b979435b7b244145d5444f7f577a69653fd07b57 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 15 Sep 2026 12:25:07 -0600 Subject: [PATCH 2/4] fix(solid-db): reconcile by live result identity --- packages/solid-db/src/useLiveQuery.ts | 39 ++++- packages/solid-db/tests/useLiveQuery.test.tsx | 159 +++++++++++++++++- 2 files changed, 189 insertions(+), 9 deletions(-) diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 41c136be26..96569ba977 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -329,7 +329,13 @@ export function useLiveQuery( // Reactive state that gets updated granularly through change events const state = new ReactiveMap() - // Reactive data array that maintains sorted order + // Keep the live Collection's result keys private while preserving one stable + // Solid store per logical row. A row's public $key can belong to an upstream + // Collection and is therefore not necessarily unique in this result. + const rowsByKey = new Map< + string | number, + { value: any; update: (value: any) => void } + >() const [data, setData] = createStore>([], { name: `TanstackDBData`, }) @@ -346,11 +352,31 @@ export function useLiveQuery( const syncDataFromCollection = ( currentCollection: Collection, ) => { - setData((prev) => - reconcile(Array.from(currentCollection.values()), { key: `$key` })( - prev, - ).filter(Boolean), - ) + const nextRows: Array = [] + const retainedKeys = new Set() + + for (const [key, value] of currentCollection.entries()) { + retainedKeys.add(key) + + const existing = rowsByKey.get(key) + if (existing) { + existing.update(value) + nextRows.push(existing.value) + } else { + const [row, setRow] = createStore(value) + rowsByKey.set(key, { + value: row, + update: (nextValue) => setRow(reconcile(nextValue, { key: null })), + }) + nextRows.push(row) + } + } + + for (const key of rowsByKey.keys()) { + if (!retainedKeys.has(key)) rowsByKey.delete(key) + } + + setData((previous) => reconcile(nextRows, { key: null })(previous)) } // Generation guard for the resource's async continuations: Solid discards a @@ -399,6 +425,7 @@ export function useLiveQuery( if (!currentCollection) { setStatus(`disabled` as const) state.clear() + rowsByKey.clear() setData([]) return } diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index 626e29c5ed..bd2f795b88 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -2317,6 +2317,9 @@ describe(`Query Collections`, () => { }), ) const renderedKeys: Array = [] + const initialNodes = new Map() + const initialTokens = new Map() + let tokenSequence = 0 function TestComponent() { const query = useLiveQuery((q) => @@ -2333,8 +2336,15 @@ describe(`Query Collections`, () => { {(item) => { renderedKeys.push(item.$key) + const keyAtCreation = item._id + const token = `mapper-${++tokenSequence}` + initialTokens.set(keyAtCreation, token) return ( -
  • +
  • initialNodes.set(keyAtCreation, node)} + data-row-key={item.$key} + data-token={token} + > {item._id}:{item.name}
  • ) @@ -2350,15 +2360,25 @@ describe(`Query Collections`, () => { (element) => ({ key: element.getAttribute(`data-row-key`), text: element.textContent, + token: element.getAttribute(`data-token`), + node: element, }), ) + const readRenderedValues = () => + readRenderedRows().map(({ key, text }) => ({ key, text })) + const expectedIdentityRows = () => + expectedRows().map(({ key }) => ({ + key, + token: initialTokens.get(key), + retainedOwnNode: true, + })) await waitFor(() => { expect(rendered.getByTestId(`custom-key-list`).dataset.ready).toBe( `true`, ) expect(renderedKeys).toEqual([`bob1`, `kevin1`, `stuart1`]) - expect(readRenderedRows()).toEqual(expectedRows()) + expect(readRenderedValues()).toEqual(expectedRows()) }) const updatedItem = { _id: `stuart1`, name: `Alvin` } @@ -2369,10 +2389,143 @@ describe(`Query Collections`, () => { await waitFor(() => { expect(collection.get(`stuart1`)?.name).toBe(`Alvin`) - expect(readRenderedRows()).toEqual(expectedRows()) + expect(readRenderedValues()).toEqual(expectedRows()) + expect( + readRenderedRows().map(({ key, token, node }) => ({ + key, + token, + retainedOwnNode: node === initialNodes.get(key!), + })), + ).toEqual(expectedIdentityRows()) }) }) + it(`keeps union rows with colliding public keys tied to their live result identities`, async () => { + type UnionItem = { + id: string + label: string + } + + const left = createCollection( + mockSyncCollectionOptions({ + id: `solid-colliding-union-left`, + getKey: (item) => item.id, + initialData: [{ id: `shared`, label: `Left` }], + }), + ) + const right = createCollection( + mockSyncCollectionOptions({ + id: `solid-colliding-union-right`, + getKey: (item) => item.id, + initialData: [{ id: `shared`, label: `Right` }], + }), + ) + const live = createLiveQueryCollection((q) => + q.unionAll(q.from({ left }), q.from({ right })), + ) + const initialNodes = new Map() + const initialTokens = new Map() + let tokenSequence = 0 + + function TestComponent() { + const query = useLiveQuery(() => live) + return ( +
      + + {(item) => { + const labelAtCreation = item.label + const token = `mapper-${++tokenSequence}` + initialTokens.set(labelAtCreation, token) + return ( +
    1. initialNodes.set(labelAtCreation, node)} + data-label={item.label} + data-token={token} + data-upstream-key={item.$key} + > + {item.label} +
    2. + ) + }} +
      +
    + ) + } + + const rendered = render(() => ) + const list = () => rendered.getByTestId(`colliding-union-list`) + const renderedRows = () => + Array.from(list().children).map((element) => ({ + label: element.getAttribute(`data-label`), + text: element.textContent, + token: element.getAttribute(`data-token`), + upstreamKey: element.getAttribute(`data-upstream-key`), + node: element, + })) + + await waitFor(() => { + expect(list().dataset.ready).toBe(`true`) + expect(list().dataset.hookCount).toBe(`2`) + expect( + renderedRows().map(({ label, text, upstreamKey }) => ({ + label, + text, + upstreamKey, + })), + ).toEqual([ + { label: `Left`, text: `Left`, upstreamKey: `shared` }, + { label: `Right`, text: `Right`, upstreamKey: `shared` }, + ]) + }) + + const initialLiveRows = [...live.entries()].map(([resultKey, item]) => ({ + resultKey, + label: item.label, + upstreamKey: item.$key, + })) + expect( + new Set(initialLiveRows.map(({ resultKey }) => resultKey)).size, + ).toBe(2) + expect(initialLiveRows.map(({ upstreamKey }) => upstreamKey)).toEqual([ + `shared`, + `shared`, + ]) + + left.utils.begin() + left.utils.write({ + type: `delete`, + value: { id: `shared`, label: `Left` }, + }) + left.utils.commit() + + await waitFor(() => { + expect(live.toArray.map(({ label }) => label)).toEqual([`Right`]) + expect(list().dataset.hookCount).toBe(`1`) + expect( + renderedRows().map(({ label, text, token, node }) => ({ + label, + text, + token, + retainedOwnNode: node === initialNodes.get(label!), + })), + ).toEqual([ + { + label: `Right`, + text: `Right`, + token: initialTokens.get(`Right`), + retainedOwnNode: true, + }, + ]) + }) + + rendered.unmount() + expect(rendered.container.childElementCount).toBe(0) + }) + it(`should reflect optimistic inserts in the data array and reconcile after sync`, async () => { const collection = createCollection( mockSyncCollectionOptions({ From b62a53c669c273e6f6d86cc285fa1334d02be716 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 15 Sep 2026 12:28:16 -0600 Subject: [PATCH 3/4] docs(changeset): describe result identity fix --- .changeset/fix-solid-custom-key-reconciliation.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-solid-custom-key-reconciliation.md b/.changeset/fix-solid-custom-key-reconciliation.md index 956fd08b24..89e81d55c5 100644 --- a/.changeset/fix-solid-custom-key-reconciliation.md +++ b/.changeset/fix-solid-custom-key-reconciliation.md @@ -2,4 +2,5 @@ '@tanstack/solid-db': patch --- -Keep custom-key rows distinct when live query updates change their rendered order. +Keep Solid live-query rows tied to their result identities when custom-key rows +reorder or multiple results share the same public `$key`. From 84740b9c0165d97e1ccd723f66ad183fe45cd8e6 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 15 Sep 2026 14:51:06 -0600 Subject: [PATCH 4/4] fix(solid-db): reset identity across collection switches --- packages/solid-db/src/useLiveQuery.ts | 7 ++ packages/solid-db/tests/useLiveQuery.test.tsx | 83 ++++++++++++++++++- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 96569ba977..67e421be7a 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -336,6 +336,7 @@ export function useLiveQuery( string | number, { value: any; update: (value: any) => void } >() + let rowsCollection: Collection | undefined const [data, setData] = createStore>([], { name: `TanstackDBData`, }) @@ -426,10 +427,16 @@ export function useLiveQuery( setStatus(`disabled` as const) state.clear() rowsByKey.clear() + rowsCollection = undefined setData([]) return } + if (rowsCollection !== currentCollection) { + rowsByKey.clear() + rowsCollection = currentCollection + } + // The shared observer owns subscription, the ready-race, and status; Solid // materializes into its keyed ReactiveMap (granular) + reconciled store. const observer = createLiveQueryObserver(currentCollection) diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index bd2f795b88..e92572a2ae 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -117,6 +117,69 @@ describe(`Query Collections`, () => { }) }) + it(`remounts overlapping row keys when switching collection identity`, async () => { + type SwitchItem = { id: string; label: string } + const first = createCollection( + mockSyncCollectionOptions({ + id: `solid-overlapping-switch-first`, + getKey: (item) => item.id, + initialData: [{ id: `shared`, label: `First` }], + }), + ) + const second = createCollection( + mockSyncCollectionOptions({ + id: `solid-overlapping-switch-second`, + getKey: (item) => item.id, + initialData: [{ id: `shared`, label: `Second` }], + }), + ) + first.startSyncImmediate() + second.startSyncImmediate() + + const [current, setCurrent] = createSignal( + first, + ) + let mount = 0 + const rendered = render(() => { + const result = useLiveQuery(current) + return ( +
      + + {(item) => { + const token = `mount-${++mount}` + return ( +
    1. + {item.label} +
    2. + ) + }} +
      +
    + ) + }) + const row = () => + rendered.getByTestId(`overlapping-switch-list`).children[0] as + | HTMLLIElement + | undefined + + try { + await waitFor(() => expect(row()?.textContent).toBe(`First`)) + const firstNode = row() + const firstToken = firstNode?.dataset.token + + setCurrent(second) + + await waitFor(() => expect(row()?.textContent).toBe(`Second`)) + expect(row()).not.toBe(firstNode) + expect(row()?.dataset.token).not.toBe(firstToken) + expect(mount).toBe(2) + } finally { + rendered.unmount() + await first.cleanup() + await second.cleanup() + } + }) + it(`should work with basic collection and select`, async () => { const collection = createCollection( mockSyncCollectionOptions({ @@ -2338,10 +2401,16 @@ describe(`Query Collections`, () => { renderedKeys.push(item.$key) const keyAtCreation = item._id const token = `mapper-${++tokenSequence}` - initialTokens.set(keyAtCreation, token) + if (!initialTokens.has(keyAtCreation)) { + initialTokens.set(keyAtCreation, token) + } return (
  • initialNodes.set(keyAtCreation, node)} + ref={(node) => { + if (!initialNodes.has(keyAtCreation)) { + initialNodes.set(keyAtCreation, node) + } + }} data-row-key={item.$key} data-token={token} > @@ -2439,10 +2508,16 @@ describe(`Query Collections`, () => { {(item) => { const labelAtCreation = item.label const token = `mapper-${++tokenSequence}` - initialTokens.set(labelAtCreation, token) + if (!initialTokens.has(labelAtCreation)) { + initialTokens.set(labelAtCreation, token) + } return (
  • initialNodes.set(labelAtCreation, node)} + ref={(node) => { + if (!initialNodes.has(labelAtCreation)) { + initialNodes.set(labelAtCreation, node) + } + }} data-label={item.label} data-token={token} data-upstream-key={item.$key}