diff --git a/.changeset/heavy-pandas-tell.md b/.changeset/heavy-pandas-tell.md new file mode 100644 index 0000000000..0d2a301d73 --- /dev/null +++ b/.changeset/heavy-pandas-tell.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +Materialize includes into parent rows before they commit so emitted change events already carry their included values. The includes flush ran only after the parent live query collection committed, patching stored rows in place without emitting; any consumer that copies rows at emit time — most commonly another live query layered on the collection, the shape framework adapters produce for component-level queries — captured the pre-patch row with its included fields unset. A parent-only update (no child changes) never triggered the follow-up re-emit, so those consumers kept empty includes until an unrelated child change recomputed the row. Fixes #1635. diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 4d80b3fe17..210ec6022b 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -808,6 +808,7 @@ export class CollectionConfigBuilder< // 1. Flush parent changes if (hasParentChanges) { + patchParentChangesFromIncludes(includesState, changesToApply) begin() changesToApply.forEach(this.applyChanges.bind(this, config)) commit() @@ -1238,6 +1239,41 @@ function materializesInline(state: IncludesOutputState): boolean { return state.materialization !== `collection` } +/** + * Materializes the current includes state into freshly computed parent + * change values, so the change events emitted on commit already carry + * their included values. + */ +function patchParentChangesFromIncludes( + includesState: Array, + parentChanges: Map>, +): void { + for (const state of includesState) { + if (!materializesInline(state)) continue + for (const [, changes] of parentChanges) { + if (changes.inserts > 0) { + const parentResult = changes.value as Record + const routing = parentResult[INCLUDES_ROUTING]?.[state.fieldName] + const correlationKey = routing?.correlationKey + if (correlationKey != null) { + const routingKey = computeRoutingKey( + correlationKey, + routing?.parentContext ?? null, + ) + setIncludedValue( + parentResult, + state.resultPath, + materializeIncludedValue( + state, + state.childRegistry.get(routingKey), + ), + ) + } + } + } + } +} + function materializeIncludedValue( state: IncludesOutputState, entry: ChildCollectionEntry | undefined, diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 2eba6f122a..829f5f61bf 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -711,6 +711,53 @@ describe(`includes subqueries`, () => { }) }) + describe(`parent-only updates`, () => { + it(`keeps materialized includes on rows observed through a layered live query`, async () => { + const collection = createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: materialize( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ + id: i.id, + title: i.title, + })), + ), + })), + ) + await collection.preload() + + const layered = createLiveQueryCollection((q) => + q.from({ row: collection }), + ) + await layered.preload() + + expect(sortedPlainRows(layered.get(1)!.issues)).toEqual([ + { id: 10, title: `Bug in Alpha` }, + { id: 11, title: `Feature for Alpha` }, + ]) + + projects.utils.begin() + projects.utils.write({ + type: `update`, + value: { id: 1, name: `Alpha renamed` }, + }) + projects.utils.commit() + + await vi.waitFor(() => { + expect(layered.get(1)?.name).toBe(`Alpha renamed`) + }) + + expect(sortedPlainRows(layered.get(1)!.issues)).toEqual([ + { id: 10, title: `Bug in Alpha` }, + { id: 11, title: `Feature for Alpha` }, + ]) + }) + }) + describe(`inner join filtering`, () => { it(`only shows children for parents matching a WHERE clause`, async () => { const collection = createLiveQueryCollection((q) =>