-
Notifications
You must be signed in to change notification settings - Fork 199
includes: child collections get garbage collected, losing data permanently #1429
Description
Bug Description
Child collections created by createChildCollectionEntry in collection-config-builder.ts do not specify a gcTime, so they inherit the default of 300,000ms (5 minutes). When the only React subscriber to a child collection unmounts (e.g., due to virtual table row recycling, tab switching, or conditional rendering), the subscriber count drops to 0, the GC timer starts (lifecycle.ts:165), and after 5 minutes the collection is cleaned up (performCleanup → status cleaned-up, all data cleared).
The includes system has no mechanism to re-populate a garbage-collected child collection. The data is permanently lost until the parent live query is fully re-created (e.g., page refresh).
Steps to Reproduce
- Create a query with nested subquery in
.select()that produces child collections:
const { data: projects } = useLiveQuery((q) =>
q.from({ project: projectsCollection })
.select(({ project }) => ({
...project,
issues: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, project.id)),
}))
, [])- Consume child collections in a component that can unmount (e.g., a virtualized list):
function IssueCount({ issues }) {
const { data = [] } = useLiveQuery(issues) // issues is the child Collection
return <span>{data.length} issues</span>
}- Unmount the consumer component (scroll away in a virtual list, switch tabs, conditional render)
- Wait 5+ minutes
- Remount the component — child collection data is gone
Observed Behavior
- Empty child collection count grows progressively over time as more collections cross the 5-minute GC threshold
- All empty child collections have
status: 'cleaned-up',syncedDataSize: 0 - Data never recovers without a full page refresh
- Adding a
subscribeChangesmonitor to child collections prevents the bug (Heisenberg effect), confirming the root cause is subscriber-count-based GC
Expected Behavior
Child collections managed by the includes system should not be subject to time-based GC, since their lifecycle is already managed by Phase 5 of flushIncludesState (which deletes child collections from childRegistry when parent rows are removed).
Root Cause
In createChildCollectionEntry (~line 1504 of collection-config-builder.ts):
const collection = createCollection<any, string | number>({
id: `__child-collection:${parentId}-${fieldName}-${serializeValue(correlationKey)}`,
getKey: (item: any) => resultKeys.get(item) as string | number,
compare,
sync: { ... },
startSync: true,
// ← missing gcTime: 0
})No gcTime specified → defaults to 300,000ms (lifecycle.ts:166).
Proposed Fix
Add gcTime: 0 to disable external GC on child collections:
const collection = createCollection<any, string | number>({
id: `__child-collection:${parentId}-${fieldName}-${serializeValue(correlationKey)}`,
getKey: (item: any) => resultKeys.get(item) as string | number,
compare,
gcTime: 0, // Disable GC — lifecycle managed by includes system (flushIncludesState Phase 5)
sync: { ... },
startSync: true,
})This is safe because:
gcTime: 0disables the GC timer (lifecycle.ts:171:if (gcTime <= 0) return)- Child collection cleanup is already handled by
flushIncludesStatePhase 5 - External GC is both unnecessary and harmful for these collections
Suggested Regression Test
Following the existing patterns in packages/db/tests/query/includes.test.ts:
it('child collections survive GC after subscriber unsubscribes', async () => {
vi.useFakeTimers()
const projects = createProjectsCollection()
const issues = createIssuesCollection()
const collection = createLiveQueryCollection((q) =>
q.from({ p: projects }).select(({ p }) => ({
...p,
issues: q
.from({ i: issues })
.where(({ i }) => eq(i.projectId, p.id)),
})),
)
await collection.preload()
const alpha = collection.get(1) as any
expect(childItems(alpha.issues)).toHaveLength(2)
// Simulate React unmount: subscribe then unsubscribe to child collection
const sub = alpha.issues.subscribeChanges(() => {})
sub.unsubscribe()
// Advance past default GC timeout (5 minutes)
vi.advanceTimersByTime(600_000)
await vi.runAllTimersAsync()
// Child collection should still have data (not GC'd)
expect(childItems(alpha.issues)).toHaveLength(2)
expect(alpha.issues.status).not.toBe('cleaned-up')
vi.useRealTimers()
})Environment
@tanstack/db: 0.6.0- Reproduction: any
includesusage where child collection consumers unmount for >5 minutes
Workaround
pnpm patch adding gcTime: 0 to the createCollection call in createChildCollectionEntry across the ESM/CJS dist files.
Automated Fix (for Claude Code / Cursor / similar)
Paste this prompt into your coding agent:
There is a bug in @tanstack/db@0.6.x where child collections created by the includes system (nested subqueries in .select()) get garbage collected after 5 minutes when React subscribers unmount. This causes data loss in virtualized lists, tab switches, or any component unmount scenario.
Apply a pnpm patch to @tanstack/db that adds
gcTime: 0to thecreateCollectioncall inside thecreateChildCollectionEntryfunction incollection-config-builder. This needs to be patched in 3 files:
src/query/live/collection-config-builder.ts— addgcTime: 0,afterstartSync: true,dist/esm/query/live/collection-config-builder.js— addgcTime: 0afterstartSync: truedist/cjs/query/live/collection-config-builder.cjs— addgcTime: 0afterstartSync: trueUse
pnpm patch @tanstack/dbto extract, edit the 3 files, thenpnpm patch-commit <path>to finalize. Verify the patch applied by checking thatgcTime: 0appears innode_modules/@tanstack/db/dist/esm/query/live/collection-config-builder.js.