From c6b2860561c21c5e3edab6ea194e65dcfd8c2794 Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:10:47 -0700 Subject: [PATCH 1/2] fix(solid-db): clone rows in syncDataFromCollection so reconcile sees in-place mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useLiveQuery's data array missed updates whenever the underlying collection mutated a row in place. flushIncludesState (packages/db/src/query/live/collection-config-builder.ts) writes toArray include values onto the parent row without changing its identity, so when syncDataFromCollection called reconcile(Array.from(currentCollection.values())) Solid's "next === target" short-circuit kicked in at the parent level and the include field never reached subscribers. Shallow-clone each row, and clone any array-valued field on it, before handing the snapshot to reconcile. Field-level diffing then actually runs and toArray includes propagate. The state ReactiveMap path is unchanged because subscribeChanges already gets fresh row references from change events; only the array-sync path was affected. Regression test: useLiveQuery with a toArray include over a child collection, then insert a new child and assert the parent's include array grows. Fixes part of #1571 — the empty-initial-include null-proxy case in collection-config-builder.ts:1851-1867 is a separate concern and will be filed as its own issue. --- .changeset/solid-db-reconcile-clone-rows.md | 5 + packages/solid-db/src/useLiveQuery.ts | 27 +++++- packages/solid-db/tests/useLiveQuery.test.tsx | 92 +++++++++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 .changeset/solid-db-reconcile-clone-rows.md diff --git a/.changeset/solid-db-reconcile-clone-rows.md b/.changeset/solid-db-reconcile-clone-rows.md new file mode 100644 index 000000000..14ce4420d --- /dev/null +++ b/.changeset/solid-db-reconcile-clone-rows.md @@ -0,0 +1,5 @@ +--- +"@tanstack/solid-db": patch +--- + +Fix `useLiveQuery` not propagating `toArray` include changes. When the upstream collection mutated parent rows in place (as it does when flushing `toArray` include values), Solid's `reconcile` short-circuited on the stable parent reference and the rendered `data` array kept showing the old include content. `syncDataFromCollection` now shallow-clones each row (and any array-valued field on it) before handing it to `reconcile`, so field-level diffing actually runs. diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 5380bae00..9b83275c1 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -341,12 +341,35 @@ export function useLiveQuery( }, ) - // Helper to sync data array from collection in correct order + // Helper to sync data array from collection in correct order. + // + // `reconcile` short-circuits on `next === target` (both at the array-item + // level and at the field level), so when the collection mutates a row in + // place — as it does for `toArray` include values flushed by + // `flushIncludesState` in `packages/db/src/query/live/collection-config-builder.ts` + // — downstream consumers never see the change. Shallow-clone each row (and + // any array-valued field on it) so `reconcile` recurses into the field-level + // diff instead of bailing on the stable parent / include reference. + // Tracking issue: https://github.com/TanStack/db/issues/1571 + const cloneRowForReconcile = (v: T): T => { + if (v === null || typeof v !== `object` || Array.isArray(v)) { + return v + } + const source = v as Record + const out: Record = {} + for (const key in source) { + const val = source[key] + out[key] = Array.isArray(val) ? [...val] : val + } + return out as T + } const syncDataFromCollection = ( currentCollection: Collection, ) => { setData((prev) => - reconcile(Array.from(currentCollection.values()))(prev).filter(Boolean), + reconcile( + Array.from(currentCollection.values(), cloneRowForReconcile), + )(prev).filter(Boolean), ) } diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index 378c2a0fc..3cd97190f 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -8,6 +8,7 @@ import { createOptimisticAction, eq, gt, + toArray, } from '@tanstack/db' import { For, @@ -2454,6 +2455,97 @@ describe(`Query Collections`, () => { }) }) + /** + * @see https://github.com/TanStack/db/issues/1571 + * + * Regression: when an include (e.g. `toArray`) is updated, the parent row + * held by the collection is mutated in place. Solid's `reconcile` + * short-circuits on `next === target`, so without a clone-on-sync the + * `data` array never reflected the new child rows even though the + * underlying collection had them. + */ + describe(`toArray include reactivity (#1571)`, () => { + type IncludeProject = { id: number; name: string } + type IncludeIssue = { id: number; projectId: number; title: string } + + const initialIncludeProjects: Array = [ + { id: 1, name: `Alpha` }, + { id: 2, name: `Beta` }, + ] + const initialIncludeIssues: Array = [ + { id: 10, projectId: 1, title: `Bug in Alpha` }, + { id: 20, projectId: 2, title: `Bug in Beta` }, + ] + + it(`should reflect child inserts in the parent row's toArray include`, async () => { + const projects = createCollection( + mockSyncCollectionOptions({ + id: `toarray-include-projects`, + getKey: (p) => p.id, + initialData: initialIncludeProjects, + }), + ) + const issues = createCollection( + mockSyncCollectionOptions({ + id: `toarray-include-issues`, + getKey: (i) => i.id, + initialData: initialIncludeIssues, + }), + ) + + const rendered = renderHook(() => { + return useLiveQuery((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issueTitles: toArray( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => i.title), + ), + })), + ) + }) + + await waitFor(() => { + expect(rendered.result().length).toBe(2) + }) + + const alphaBefore = rendered + .result() + .find((r: any) => r.id === 1) as { issueTitles: Array } + expect(alphaBefore.issueTitles).toEqual([`Bug in Alpha`]) + + // Insert a child issue tied to project Alpha. Upstream mutates the + // parent row's `issueTitles` field in place; without the clone-on-sync + // fix the parent row reference stays stable and reconcile bails before + // recursing into the array. + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 11, projectId: 1, title: `Feature for Alpha` }, + }) + issues.utils.commit() + + await waitFor(() => { + const alphaAfter = rendered + .result() + .find((r: any) => r.id === 1) as { issueTitles: Array } + expect(alphaAfter.issueTitles).toEqual( + expect.arrayContaining([`Bug in Alpha`, `Feature for Alpha`]), + ) + expect(alphaAfter.issueTitles.length).toBe(2) + }) + + // Beta is untouched. + const beta = rendered.result().find((r: any) => r.id === 2) as { + issueTitles: Array + } + expect(beta.issueTitles).toEqual([`Bug in Beta`]) + }) + }) + describe(`findOne`, () => { it(`should return a single row with query builder`, async () => { const collection = createCollection( From 95d2d407022879587307185c6bea6cb60015238a Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:57:11 -0700 Subject: [PATCH 2/2] test(solid-db): add empty-include first-child variant for #1571 Per CodeRabbit, exercise the empty -> first-child transition for a toArray include. Parent starts with `issueTitles: []`, a child insert lands, the parent's array must update. The clone-on-sync path has to widen the existing empty array into a new identity so reconcile sees the change. Confirms the same code path handles both the populated-then-grow case and the empty-then-first-child case. --- packages/solid-db/tests/useLiveQuery.test.tsx | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index 3cd97190f..bdba27183 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -2544,6 +2544,62 @@ describe(`Query Collections`, () => { } expect(beta.issueTitles).toEqual([`Bug in Beta`]) }) + + it(`should update when a toArray include starts empty and gets first child`, async () => { + // Empty-include variant — the parent's `issueTitles` array starts at + // `[]` and must reflect a first child insert. The clone-on-sync path + // has to widen the existing empty array to a new one so reconcile sees + // the change. + const projects = createCollection( + mockSyncCollectionOptions({ + id: `toarray-include-projects-empty`, + getKey: (p) => p.id, + initialData: [{ id: 3, name: `Gamma` }], + }), + ) + const issues = createCollection( + mockSyncCollectionOptions({ + id: `toarray-include-issues-empty`, + getKey: (i) => i.id, + initialData: [], + }), + ) + + const rendered = renderHook(() => { + return useLiveQuery((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + issueTitles: toArray( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => i.title), + ), + })), + ) + }) + + await waitFor(() => { + const gamma = rendered.result().find((r: any) => r.id === 3) as + | { issueTitles: Array } + | undefined + expect(gamma?.issueTitles).toEqual([]) + }) + + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 30, projectId: 3, title: `First Gamma issue` }, + }) + issues.utils.commit() + + await waitFor(() => { + const gamma = rendered.result().find((r: any) => r.id === 3) as { + issueTitles: Array + } + expect(gamma.issueTitles).toEqual([`First Gamma issue`]) + }) + }) }) describe(`findOne`, () => {