diff --git a/.changeset/fix-solid-custom-key-reconciliation.md b/.changeset/fix-solid-custom-key-reconciliation.md new file mode 100644 index 0000000000..89e81d55c5 --- /dev/null +++ b/.changeset/fix-solid-custom-key-reconciliation.md @@ -0,0 +1,6 @@ +--- +'@tanstack/solid-db': patch +--- + +Keep Solid live-query rows tied to their result identities when custom-key rows +reorder or multiple results share the same public `$key`. diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index cf8d84f43d..67e421be7a 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -329,7 +329,14 @@ 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 } + >() + let rowsCollection: Collection | undefined const [data, setData] = createStore>([], { name: `TanstackDBData`, }) @@ -346,9 +353,31 @@ export function useLiveQuery( const syncDataFromCollection = ( currentCollection: Collection, ) => { - setData((prev) => - reconcile(Array.from(currentCollection.values()))(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 @@ -397,10 +426,17 @@ export function useLiveQuery( if (!currentCollection) { 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 85f9144fc1..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({ @@ -2284,6 +2347,260 @@ 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 = [] + const initialNodes = new Map() + const initialTokens = new Map() + let tokenSequence = 0 + + function TestComponent() { + const query = useLiveQuery((q) => + q + .from({ items: collection }) + .orderBy(({ items }) => items.name, `asc`), + ) + + return ( +
    + + {(item) => { + renderedKeys.push(item.$key) + const keyAtCreation = item._id + const token = `mapper-${++tokenSequence}` + if (!initialTokens.has(keyAtCreation)) { + initialTokens.set(keyAtCreation, token) + } + return ( +
  1. { + if (!initialNodes.has(keyAtCreation)) { + initialNodes.set(keyAtCreation, node) + } + }} + data-row-key={item.$key} + data-token={token} + > + {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, + 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(readRenderedValues()).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(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}` + if (!initialTokens.has(labelAtCreation)) { + initialTokens.set(labelAtCreation, token) + } + return ( +
  1. { + if (!initialNodes.has(labelAtCreation)) { + 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({