Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/solid-db-reconcile-clone-rows.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 25 additions & 2 deletions packages/solid-db/src/useLiveQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T,>(v: T): T => {
if (v === null || typeof v !== `object` || Array.isArray(v)) {
return v
}
const source = v as Record<string, unknown>
const out: Record<string, unknown> = {}
for (const key in source) {
const val = source[key]
out[key] = Array.isArray(val) ? [...val] : val
}
return out as T
}
const syncDataFromCollection = (
currentCollection: Collection<any, any, any>,
) => {
setData((prev) =>
reconcile(Array.from(currentCollection.values()))(prev).filter(Boolean),
reconcile(
Array.from(currentCollection.values(), cloneRowForReconcile),
)(prev).filter(Boolean),
)
}

Expand Down
148 changes: 148 additions & 0 deletions packages/solid-db/tests/useLiveQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createOptimisticAction,
eq,
gt,
toArray,
} from '@tanstack/db'
import {
For,
Expand Down Expand Up @@ -2454,6 +2455,153 @@ 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<IncludeProject> = [
{ id: 1, name: `Alpha` },
{ id: 2, name: `Beta` },
]
const initialIncludeIssues: Array<IncludeIssue> = [
{ 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<IncludeProject>({
id: `toarray-include-projects`,
getKey: (p) => p.id,
initialData: initialIncludeProjects,
}),
)
const issues = createCollection(
mockSyncCollectionOptions<IncludeIssue>({
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<string> }
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<string> }
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<string>
}
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<IncludeProject>({
id: `toarray-include-projects-empty`,
getKey: (p) => p.id,
initialData: [{ id: 3, name: `Gamma` }],
}),
)
const issues = createCollection(
mockSyncCollectionOptions<IncludeIssue>({
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<string> }
| 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<string>
}
expect(gamma.issueTitles).toEqual([`First Gamma issue`])
})
})
})

describe(`findOne`, () => {
it(`should return a single row with query builder`, async () => {
const collection = createCollection(
Expand Down