Skip to content

includes: child collections get garbage collected, losing data permanently #1429

@blj

Description

@blj

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

  1. 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)),
    }))
, [])
  1. 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>
}
  1. Unmount the consumer component (scroll away in a virtual list, switch tabs, conditional render)
  2. Wait 5+ minutes
  3. 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 subscribeChanges monitor 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: 0 disables the GC timer (lifecycle.ts:171: if (gcTime <= 0) return)
  • Child collection cleanup is already handled by flushIncludesState Phase 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 includes usage 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: 0 to the createCollection call inside the createChildCollectionEntry function in collection-config-builder. This needs to be patched in 3 files:

  1. src/query/live/collection-config-builder.ts — add gcTime: 0, after startSync: true,
  2. dist/esm/query/live/collection-config-builder.js — add gcTime: 0 after startSync: true
  3. dist/cjs/query/live/collection-config-builder.cjs — add gcTime: 0 after startSync: true

Use pnpm patch @tanstack/db to extract, edit the 3 files, then pnpm patch-commit <path> to finalize. Verify the patch applied by checking that gcTime: 0 appears in node_modules/@tanstack/db/dist/esm/query/live/collection-config-builder.js.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions