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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions