diff --git a/.changeset/fix-subquery-in-select-empty-outer-hang.md b/.changeset/fix-subquery-in-select-empty-outer-hang.md new file mode 100644 index 0000000000..1188eb4bb1 --- /dev/null +++ b/.changeset/fix-subquery-in-select-empty-outer-hang.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Fix `useLiveSuspenseQuery` (and any live query) hanging forever when a subquery-in-`select` source is an `on-demand` collection and the outer query returns zero rows. The lazy mechanism that loads inner-collection rows per outer row never fired its `loadSubset` (no parent rows → no per-row tap), so the on-demand inner stayed not-ready and `allCollectionsReady` never went true. Lazy aliases (subquery-in-select inner aliases and lazy-join inner aliases) are now skipped by the readiness gate; the existing `isLoadingSubset` gate still keeps the live query from marking ready while in-flight per-row loads are pending. diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 7353b2116f..86487ea247 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -1021,9 +1021,18 @@ export class CollectionConfigBuilder< } private allCollectionsReady() { - return Object.values(this.collections).every((collection) => - collection.isReady(), - ) + // Skip lazy aliases: they load per outer row via per-row taps, so an + // empty outer never fires loadSubset on a cold on-demand inner. The + // live query's isLoadingSubset gate still waits for in-flight per-row + // loads when the outer is non-empty. + for (const [alias, collectionId] of Object.entries( + this.compiledAliasToCollectionId, + )) { + if (this.lazySources.has(alias)) continue + const collection = this.collections[collectionId] + if (collection && !collection.isReady()) return false + } + return true } /** diff --git a/packages/db/tests/query/includes-lazy-loading.test.ts b/packages/db/tests/query/includes-lazy-loading.test.ts index 8e8eccace3..01c7cae25f 100644 --- a/packages/db/tests/query/includes-lazy-loading.test.ts +++ b/packages/db/tests/query/includes-lazy-loading.test.ts @@ -676,3 +676,191 @@ describe(`includes child where clauses in loadSubset`, () => { expect(hasStatusFilter).toBe(true) }) }) + +describe(`subquery-in-select readiness with cold on-demand inner`, () => { + /** + * A live query whose select contains a subquery against an on-demand + * collection must reach ready even when the outer returns zero rows and + * no other consumer has warmed the inner. The lazy mechanism does not + * fire loadSubset on the inner (no parent rows → no per-row tap), so + * the inner must not block allCollectionsReady. + * + * These tests use an inner collection that becomes ready ONLY via + * loadSubset (no markReady in initial sync) — calling markReady in + * sync would mask the bug. + */ + + type Post = { id: number; authorId: string; title: string } + type Comment = { id: number; postId: number; body: string } + + function createPostsCollection(initial: Array) { + const loadSubsetCalls: Array = [] + + const collection = createCollection({ + id: `subq-cold-posts-${Math.random().toString(36).slice(2, 8)}`, + getKey: (p) => p.id, + syncMode: `on-demand`, + sync: { + sync: ({ begin, write, commit, markReady }) => { + // Intentionally do NOT call markReady here — only via loadSubset, + // mirroring a real on-demand source where readiness is gated on + // the first loadSubset completing. + return { + loadSubset: vi.fn((options: LoadSubsetOptions) => { + loadSubsetCalls.push(options) + begin() + for (const p of initial) { + write({ type: `insert`, value: p }) + } + commit() + markReady() + return Promise.resolve() + }), + } + }, + }, + }) + + return { collection, loadSubsetCalls } + } + + function createCommentsCollection() { + const loadSubsetCalls: Array = [] + const sampleComments: Array = [ + { id: 100, postId: 1, body: `c1` }, + { id: 101, postId: 1, body: `c2` }, + { id: 200, postId: 2, body: `c3` }, + ] + + const collection = createCollection({ + id: `subq-cold-comments-${Math.random().toString(36).slice(2, 8)}`, + getKey: (c) => c.id, + syncMode: `on-demand`, + sync: { + sync: ({ begin, write, commit, markReady }) => { + // Intentionally do NOT call markReady here — only via loadSubset. + // This mirrors a real on-demand source: the collection is not + // ready until its first loadSubset completes. + return { + loadSubset: vi.fn((options: LoadSubsetOptions) => { + loadSubsetCalls.push(options) + begin() + for (const c of sampleComments) { + // Best-effort filter: if the request includes a postId IN + // filter (the lazy subquery passes one), only emit matching + // rows. Otherwise emit nothing. + const filters = extractSimpleComparisons(options.where) + const postFilter = filters.find( + (f) => f.field[0] === `postId` && f.operator === `in`, + ) + if (postFilter && Array.isArray(postFilter.value)) { + if (postFilter.value.includes(c.postId)) { + write({ type: `insert`, value: c }) + } + } + } + commit() + markReady() + return Promise.resolve() + }), + } + }, + }, + }) + + return { collection, loadSubsetCalls } + } + + // Race a promise against a short timeout. Used to detect the hang — + // if preload never resolves, the test fails fast instead of timing out + // the whole vitest run. + function withTimeout(p: Promise, ms: number, label: string) { + return Promise.race([ + p, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Timed out: ${label}`)), ms), + ), + ]) + } + + it(`should reach ready when outer is empty and inner is cold on-demand`, async () => { + const { collection: posts } = createPostsCollection([]) + const { collection: comments, loadSubsetCalls: commentsLoads } = + createCommentsCollection() + + const liveQuery = createLiveQueryCollection((q) => + q + .from({ p: posts }) + .where(({ p }) => eq(p.authorId, `X`)) + .select(({ p }) => ({ + id: p.id, + title: p.title, + comments: toArray( + q + .from({ c: comments }) + .where(({ c }) => eq(c.postId, p.id)) + .select(({ c }) => ({ id: c.id, body: c.body })), + ), + })), + ) + + // Without the fix this hangs forever (allCollectionsReady never goes + // true because the cold on-demand `comments` collection never receives + // a loadSubset call when the outer is empty). + await withTimeout(liveQuery.preload(), 1000, `liveQuery.preload()`) + + expect(liveQuery.isReady()).toBe(true) + expect(liveQuery.size).toBe(0) + // The inner was never loaded — that's fine, there were no parent rows + // to drive a per-row load. + expect(commentsLoads.length).toBe(0) + }) + + it(`should still drive per-row loadSubset on the inner when outer has rows`, async () => { + const { collection: posts } = createPostsCollection([ + { id: 1, authorId: `X`, title: `Post 1` }, + { id: 2, authorId: `X`, title: `Post 2` }, + ]) + const { collection: comments, loadSubsetCalls: commentsLoads } = + createCommentsCollection() + + const liveQuery = createLiveQueryCollection((q) => + q + .from({ p: posts }) + .where(({ p }) => eq(p.authorId, `X`)) + .select(({ p }) => ({ + id: p.id, + title: p.title, + comments: toArray( + q + .from({ c: comments }) + .where(({ c }) => eq(c.postId, p.id)) + .select(({ c }) => ({ id: c.id, body: c.body })), + ), + })), + ) + + await withTimeout(liveQuery.preload(), 1000, `liveQuery.preload()`) + + expect(liveQuery.isReady()).toBe(true) + expect(liveQuery.size).toBe(2) + // The lazy subquery should have driven at least one loadSubset call on + // the inner with an inArray(postId, [...]) filter. + expect(commentsLoads.length).toBeGreaterThan(0) + const hasCorrelation = commentsLoads.some((call) => { + const filters = extractSimpleComparisons(call.where) + return filters.some( + (f) => + f.field[0] === `postId` && + f.operator === `in` && + Array.isArray(f.value), + ) + }) + expect(hasCorrelation).toBe(true) + + const post1 = stripVirtualProps(liveQuery.get(1)) as any + const post2 = stripVirtualProps(liveQuery.get(2)) as any + expect(post1.comments).toHaveLength(2) + expect(post2.comments).toHaveLength(1) + }) +})