From 66bf854a3a1204e5cf5ef6f5ab76fca9633f852a Mon Sep 17 00:00:00 2001 From: Anton Vozghrin Date: Fri, 19 Jun 2026 11:51:04 +0300 Subject: [PATCH 1/4] fix(db): emit change on order-only reorder in ordered live queries --- .changeset/orderby-reorder-emission.md | 5 + .../query/live/collection-config-builder.ts | 68 ++++++++ packages/db/src/query/live/types.ts | 5 + .../tests/live-query-orderby-reorder.test.ts | 155 ++++++++++++++++++ .../useLiveQuery-orderby-reorder.test.tsx | 47 ++++++ 5 files changed, 280 insertions(+) create mode 100644 .changeset/orderby-reorder-emission.md create mode 100644 packages/db/tests/live-query-orderby-reorder.test.ts create mode 100644 packages/react-db/tests/useLiveQuery-orderby-reorder.test.tsx diff --git a/.changeset/orderby-reorder-emission.md b/.changeset/orderby-reorder-emission.md new file mode 100644 index 000000000..5116473ec --- /dev/null +++ b/.changeset/orderby-reorder-emission.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +Fix live queries with `orderBy` not emitting a change when a row's position changes but its projected value does not. Previously, a query like `q.from(...).orderBy((r) => r.value).select((r) => ({ id: r.id }))` would silently keep the stale order after a reorder (the collection's value-diff suppressed the move because the projected value was unchanged), so `useLiveQuery` rendered the old order. Order-only moves are now detected from the orderBy operator's retract/insert pair and emitted directly, with no effect on collections without `orderBy` and no double-emit when the sort field is part of the projection. diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index b40e6e431..6e8aec04e 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -5,6 +5,7 @@ import { compileQuery, } from '../compiler/index.js' import { createCollection } from '../../collection/index.js' +import { deepEquals } from '../../utils.js' import { MissingAliasInputsError, SetWindowRequiresOrderByError, @@ -797,6 +798,11 @@ export class CollectionConfigBuilder< existing.orderByIndex = changes.orderByIndex } } + // Keep the previous value/index from the retract side (the old row) + if (changes.deletes > 0 && changes.previousValue !== undefined) { + existing.previousValue = changes.previousValue + existing.previousOrderByIndex = changes.previousOrderByIndex + } } else { merged.set(customKey, { ...changes }) } @@ -809,6 +815,9 @@ export class CollectionConfigBuilder< begin() changesToApply.forEach(this.applyChanges.bind(this, config)) commit() + // Order-only moves (same value, new orderByIndex) are suppressed by the + // collection's value-diff; emit them directly so ordered consumers update. + this.emitOrderOnlyMoves(config, changesToApply) } pendingChanges = new Map() @@ -967,6 +976,61 @@ export class CollectionConfigBuilder< } } + /** + * Emit `update` events for rows whose position changed but whose value did + * not. The collection's synced commit diffs by value (deepEquals), so a pure + * reorder — same projected value, new orderByIndex — is silently swallowed + * even though the sorted output order changed. We detect those moves from the + * retract/insert pair the orderBy operator already streams and emit directly, + * mirroring the includes-materialization direct-emit pattern. + * + * The gate (index changed AND value unchanged) is mutually exclusive with the + * collection's own emit (which fires only when the value changed), so this can + * never double-emit, and it is a no-op for collections without orderBy. + */ + private emitOrderOnlyMoves( + config: SyncMethods, + changesToApply: Map>, + ): void { + const moves: Array> = [] + for (const changes of changesToApply.values()) { + const { + deletes, + inserts, + value, + orderByIndex, + previousValue, + previousOrderByIndex, + } = changes + if ( + inserts > 0 && + deletes > 0 && + orderByIndex !== undefined && + previousOrderByIndex !== undefined && + orderByIndex !== previousOrderByIndex && + previousValue !== undefined && + deepEquals(previousValue, value) + ) { + moves.push({ + type: `update`, + key: config.collection.getKeyFromItem(value), + value, + previousValue, + }) + } + } + + if (moves.length > 0) { + const changesManager = (config.collection as any)._changes as { + emitEvents: ( + changes: Array>, + forceEmit?: boolean, + ) => void + } + changesManager.emitEvents(moves, true) + } + } + /** * Handle status changes from source collections */ @@ -2009,6 +2073,10 @@ function accumulateChanges( } if (multiplicity < 0) { changes.deletes += Math.abs(multiplicity) + // Capture the retracted (old) value/index so an order-only move can be + // detected later, even when the retract and insert arrive in either order. + changes.previousValue = value + changes.previousOrderByIndex = orderByIndex } else if (multiplicity > 0) { changes.inserts += multiplicity // Update value to the latest version for this key diff --git a/packages/db/src/query/live/types.ts b/packages/db/src/query/live/types.ts index 118015bd6..19d43360f 100644 --- a/packages/db/src/query/live/types.ts +++ b/packages/db/src/query/live/types.ts @@ -16,6 +16,11 @@ export type Changes = { inserts: number value: T orderByIndex: string | undefined + // The retracted (old) side of a change, captured so the live query can detect + // an order-only move (same value, different orderByIndex) that the collection's + // value-diff would otherwise suppress. + previousValue?: T + previousOrderByIndex?: string | undefined } export type SyncState = { diff --git a/packages/db/tests/live-query-orderby-reorder.test.ts b/packages/db/tests/live-query-orderby-reorder.test.ts new file mode 100644 index 000000000..a6f044c2a --- /dev/null +++ b/packages/db/tests/live-query-orderby-reorder.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { createCollection, createLiveQueryCollection } from '../src/index' + +const flush = () => new Promise((r) => setTimeout(r, 20)) + +type Item = { id: string; name: string; value: number } + +function manualCollection(id: string, initial: Array) { + let fns: any + const collection = createCollection({ + id, + getKey: (i) => i.id, + sync: { + sync: (params) => { + fns = params + params.begin() + for (const item of initial) params.write({ type: `insert`, value: item }) + params.commit() + params.markReady() + }, + }, + }) + collection.startSyncImmediate() + const write = (type: `insert` | `update` | `delete`, value: Item) => { + fns.begin() + fns.write({ type, value }) + fns.commit() + } + return { collection, write } +} + +const seed: Array = [ + { id: `a`, name: `Alice`, value: 2 }, + { id: `b`, name: `Bob`, value: 1 }, + { id: `c`, name: `Carol`, value: 3 }, +] + +describe(`live query orderBy: order-only reorder must emit a change`, () => { + it(`select(id) + orderBy(value): a reorder emits a change and updates order`, async () => { + const { collection: source, write } = manualCollection(`reorder-emit-src`, [ + seed[0]!, + seed[1]!, + ]) + const live = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ s: source }) + .orderBy(({ s }) => s.value) + .select(({ s }) => ({ id: s.id })), + }) + await flush() + + const orderOf = () => Array.from(live.values(), (v: any) => v.id as string) + expect(orderOf()).toEqual([`b`, `a`]) + + let emitted = 0 + const sub = live.subscribeChanges(() => { + emitted++ + }) + + write(`update`, { id: `a`, name: `Alice`, value: 0 }) + await flush() + + expect(emitted).toBeGreaterThan(0) + expect(orderOf()).toEqual([`a`, `b`]) + sub.unsubscribe() + }) + + it(`select(id, value) + orderBy(value): reorder emits EXACTLY one event for the moved key (no double-emit)`, async () => { + const { collection: source, write } = manualCollection(`reorder-single`, [ + seed[0]!, + seed[1]!, + ]) + const live = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ s: source }) + .orderBy(({ s }) => s.value) + .select(({ s }) => ({ id: s.id, value: s.value })), + }) + await flush() + + let eventsForA = 0 + const sub = live.subscribeChanges((changes) => { + for (const c of changes) if (c.key === `a`) eventsForA++ + }) + + // reorder by changing the (projected) sort field + write(`update`, { id: `a`, name: `Alice`, value: 0 }) + await flush() + + expect(eventsForA).toBe(1) // state.ts emits once; direct-emit is gated off + expect(Array.from(live.values(), (v: any) => v.id)).toEqual([`a`, `b`]) + sub.unsubscribe() + }) + + it(`no reorder + unprojected field change: order-only path stays silent`, async () => { + const { collection: source, write } = manualCollection(`no-move`, [ + seed[0]!, + seed[1]!, + ]) + const live = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ s: source }) + .orderBy(({ s }) => s.value) + .select(({ s }) => ({ id: s.id })), + }) + await flush() + + let emitted = 0 + const sub = live.subscribeChanges(() => { + emitted++ + }) + + // change a non-projected, non-sort field; position is unchanged + write(`update`, { id: `a`, name: `Alicia`, value: 2 }) + await flush() + + expect(emitted).toBe(0) // nothing visible changed + expect(Array.from(live.values(), (v: any) => v.id)).toEqual([`b`, `a`]) + sub.unsubscribe() + }) + + it(`select(id) + orderBy + limit: reorder within the window emits and reorders`, async () => { + const { collection: source, write } = manualCollection(`reorder-limit`, seed) + const live = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ s: source }) + .orderBy(({ s }) => s.value) + .limit(3) + .select(({ s }) => ({ id: s.id })), + }) + await flush() + expect(Array.from(live.values(), (v: any) => v.id)).toEqual([`b`, `a`, `c`]) + + let emitted = 0 + const sub = live.subscribeChanges(() => { + emitted++ + }) + + // move c to the front (3 -> 0) + write(`update`, { id: `c`, name: `Carol`, value: 0 }) + await flush() + + expect(emitted).toBeGreaterThan(0) + expect(Array.from(live.values(), (v: any) => v.id)).toEqual([`c`, `b`, `a`]) + sub.unsubscribe() + }) +}) diff --git a/packages/react-db/tests/useLiveQuery-orderby-reorder.test.tsx b/packages/react-db/tests/useLiveQuery-orderby-reorder.test.tsx new file mode 100644 index 000000000..1af0d8264 --- /dev/null +++ b/packages/react-db/tests/useLiveQuery-orderby-reorder.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { act, renderHook, waitFor } from '@testing-library/react' +import { createCollection } from '@tanstack/db' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { useLiveQuery } from '../src/useLiveQuery' + +type Item = { id: string; value: number } + +function make(id: string, initialData: Array) { + const collection = createCollection( + mockSyncCollectionOptions({ id, getKey: (i) => i.id, initialData }), + ) + const write = (type: `insert` | `update` | `delete`, value: Item) => { + collection.utils.begin() + collection.utils.write({ type, value }) + collection.utils.commit() + } + return { collection, write } +} + +describe(`useLiveQuery orderBy + select(id-only): reorder is reflected`, () => { + it(`a reorder updates the rendered id order even when value is not selected`, async () => { + const { collection, write } = make(`uq-reorder`, [ + { id: `a`, value: 2 }, + { id: `b`, value: 1 }, + ]) + const { result } = renderHook(() => + useLiveQuery((q) => + q + .from({ s: collection }) + .orderBy(({ s }) => s.value) + .select(({ s }) => ({ id: s.id })), + ), + ) + + await waitFor(() => expect(result.current.data.length).toBe(2)) + expect(result.current.data.map((r: any) => r.id)).toEqual([`b`, `a`]) + + act(() => { + write(`update`, { id: `a`, value: 0 }) + }) + + await waitFor(() => + expect(result.current.data.map((r: any) => r.id)).toEqual([`a`, `b`]), + ) + }) +}) From 18f158d5a5bfdb5dfc41c2754760cfddd3bf2d5e Mon Sep 17 00:00:00 2001 From: Anton Vozghrin Date: Fri, 19 Jun 2026 15:59:03 +0300 Subject: [PATCH 2/4] test(db): make reorder tests deterministic and add limit/offset boundary cases - Drop fixed setTimeout flushes (live query is synchronous on commit) - Type the sync helper and projections instead of any - Add limit(0) and offset-beyond-length empty-window reorder cases --- .../tests/live-query-orderby-reorder.test.ts | 97 ++++++++++++++----- 1 file changed, 74 insertions(+), 23 deletions(-) diff --git a/packages/db/tests/live-query-orderby-reorder.test.ts b/packages/db/tests/live-query-orderby-reorder.test.ts index a6f044c2a..993ac52fc 100644 --- a/packages/db/tests/live-query-orderby-reorder.test.ts +++ b/packages/db/tests/live-query-orderby-reorder.test.ts @@ -1,12 +1,21 @@ import { describe, expect, it } from 'vitest' import { createCollection, createLiveQueryCollection } from '../src/index' - -const flush = () => new Promise((r) => setTimeout(r, 20)) +import type { SyncConfig } from '../src/types' type Item = { id: string; name: string; value: number } +type SyncFns = Pick< + Parameters[`sync`]>[0], + `begin` | `write` | `commit` | `markReady` +> + +// The live query processes synchronously on commit, so tests assert directly +// after each write — no timing/sleep needed. +const idsOf = (live: { values: () => IterableIterator }): Array => + Array.from(live.values(), (v) => (v as { id: string }).id) + function manualCollection(id: string, initial: Array) { - let fns: any + let fns: SyncFns | undefined const collection = createCollection({ id, getKey: (i) => i.id, @@ -22,6 +31,7 @@ function manualCollection(id: string, initial: Array) { }) collection.startSyncImmediate() const write = (type: `insert` | `update` | `delete`, value: Item) => { + if (!fns) throw new Error(`sync functions not initialized`) fns.begin() fns.write({ type, value }) fns.commit() @@ -36,7 +46,7 @@ const seed: Array = [ ] describe(`live query orderBy: order-only reorder must emit a change`, () => { - it(`select(id) + orderBy(value): a reorder emits a change and updates order`, async () => { + it(`select(id) + orderBy(value): a reorder emits a change and updates order`, () => { const { collection: source, write } = manualCollection(`reorder-emit-src`, [ seed[0]!, seed[1]!, @@ -49,10 +59,7 @@ describe(`live query orderBy: order-only reorder must emit a change`, () => { .orderBy(({ s }) => s.value) .select(({ s }) => ({ id: s.id })), }) - await flush() - - const orderOf = () => Array.from(live.values(), (v: any) => v.id as string) - expect(orderOf()).toEqual([`b`, `a`]) + expect(idsOf(live)).toEqual([`b`, `a`]) let emitted = 0 const sub = live.subscribeChanges(() => { @@ -60,14 +67,13 @@ describe(`live query orderBy: order-only reorder must emit a change`, () => { }) write(`update`, { id: `a`, name: `Alice`, value: 0 }) - await flush() expect(emitted).toBeGreaterThan(0) - expect(orderOf()).toEqual([`a`, `b`]) + expect(idsOf(live)).toEqual([`a`, `b`]) sub.unsubscribe() }) - it(`select(id, value) + orderBy(value): reorder emits EXACTLY one event for the moved key (no double-emit)`, async () => { + it(`select(id, value) + orderBy(value): reorder emits EXACTLY one event for the moved key (no double-emit)`, () => { const { collection: source, write } = manualCollection(`reorder-single`, [ seed[0]!, seed[1]!, @@ -80,7 +86,6 @@ describe(`live query orderBy: order-only reorder must emit a change`, () => { .orderBy(({ s }) => s.value) .select(({ s }) => ({ id: s.id, value: s.value })), }) - await flush() let eventsForA = 0 const sub = live.subscribeChanges((changes) => { @@ -89,14 +94,13 @@ describe(`live query orderBy: order-only reorder must emit a change`, () => { // reorder by changing the (projected) sort field write(`update`, { id: `a`, name: `Alice`, value: 0 }) - await flush() expect(eventsForA).toBe(1) // state.ts emits once; direct-emit is gated off - expect(Array.from(live.values(), (v: any) => v.id)).toEqual([`a`, `b`]) + expect(idsOf(live)).toEqual([`a`, `b`]) sub.unsubscribe() }) - it(`no reorder + unprojected field change: order-only path stays silent`, async () => { + it(`no reorder + unprojected field change: order-only path stays silent`, () => { const { collection: source, write } = manualCollection(`no-move`, [ seed[0]!, seed[1]!, @@ -109,7 +113,6 @@ describe(`live query orderBy: order-only reorder must emit a change`, () => { .orderBy(({ s }) => s.value) .select(({ s }) => ({ id: s.id })), }) - await flush() let emitted = 0 const sub = live.subscribeChanges(() => { @@ -118,14 +121,13 @@ describe(`live query orderBy: order-only reorder must emit a change`, () => { // change a non-projected, non-sort field; position is unchanged write(`update`, { id: `a`, name: `Alicia`, value: 2 }) - await flush() expect(emitted).toBe(0) // nothing visible changed - expect(Array.from(live.values(), (v: any) => v.id)).toEqual([`b`, `a`]) + expect(idsOf(live)).toEqual([`b`, `a`]) sub.unsubscribe() }) - it(`select(id) + orderBy + limit: reorder within the window emits and reorders`, async () => { + it(`select(id) + orderBy + limit: reorder within the window emits and reorders`, () => { const { collection: source, write } = manualCollection(`reorder-limit`, seed) const live = createLiveQueryCollection({ startSync: true, @@ -136,8 +138,7 @@ describe(`live query orderBy: order-only reorder must emit a change`, () => { .limit(3) .select(({ s }) => ({ id: s.id })), }) - await flush() - expect(Array.from(live.values(), (v: any) => v.id)).toEqual([`b`, `a`, `c`]) + expect(idsOf(live)).toEqual([`b`, `a`, `c`]) let emitted = 0 const sub = live.subscribeChanges(() => { @@ -146,10 +147,60 @@ describe(`live query orderBy: order-only reorder must emit a change`, () => { // move c to the front (3 -> 0) write(`update`, { id: `c`, name: `Carol`, value: 0 }) - await flush() expect(emitted).toBeGreaterThan(0) - expect(Array.from(live.values(), (v: any) => v.id)).toEqual([`c`, `b`, `a`]) + expect(idsOf(live)).toEqual([`c`, `b`, `a`]) + sub.unsubscribe() + }) + + it(`limit(0): empty window stays empty and silent across a reorder`, () => { + const { collection: source, write } = manualCollection(`reorder-limit0`, seed) + const live = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ s: source }) + .orderBy(({ s }) => s.value) + .limit(0) + .select(({ s }) => ({ id: s.id })), + }) + expect(idsOf(live)).toEqual([]) + + let emitted = 0 + const sub = live.subscribeChanges(() => { + emitted++ + }) + + write(`update`, { id: `a`, name: `Alice`, value: 0 }) + + expect(emitted).toBe(0) + expect(idsOf(live)).toEqual([]) + sub.unsubscribe() + }) + + it(`offset beyond data length: empty window stays empty and silent across a reorder`, () => { + const { collection: source, write } = manualCollection(`reorder-offset`, seed) + const live = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ s: source }) + .orderBy(({ s }) => s.value) + .offset(10) + .limit(3) + .select(({ s }) => ({ id: s.id })), + }) + expect(idsOf(live)).toEqual([]) + + let emitted = 0 + const sub = live.subscribeChanges(() => { + emitted++ + }) + + write(`update`, { id: `a`, name: `Alice`, value: 0 }) + + expect(emitted).toBe(0) + expect(idsOf(live)).toEqual([]) sub.unsubscribe() }) }) From 02a6863fbcb00a5923c7573a6fa7727935ca7475 Mon Sep 17 00:00:00 2001 From: Anton Vozghrin Date: Mon, 22 Jun 2026 09:56:51 +0300 Subject: [PATCH 3/4] test(db): add single-element and back-to-back reorder corner cases --- .../tests/live-query-orderby-reorder.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/db/tests/live-query-orderby-reorder.test.ts b/packages/db/tests/live-query-orderby-reorder.test.ts index 993ac52fc..e72e3a1ca 100644 --- a/packages/db/tests/live-query-orderby-reorder.test.ts +++ b/packages/db/tests/live-query-orderby-reorder.test.ts @@ -203,4 +203,63 @@ describe(`live query orderBy: order-only reorder must emit a change`, () => { expect(idsOf(live)).toEqual([]) sub.unsubscribe() }) + + it(`single-element collection: sort-field change cannot reorder and stays silent`, () => { + const { collection: source, write } = manualCollection(`single`, [seed[0]!]) + const live = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ s: source }) + .orderBy(({ s }) => s.value) + .select(({ s }) => ({ id: s.id })), + }) + expect(idsOf(live)).toEqual([`a`]) + + let emitted = 0 + const sub = live.subscribeChanges(() => { + emitted++ + }) + + // The only row's sort value changes, but with one element there is no move + // and the projected value is unchanged — nothing should be emitted. + write(`update`, { id: `a`, name: `Alice`, value: 99 }) + + expect(emitted).toBe(0) + expect(idsOf(live)).toEqual([`a`]) + sub.unsubscribe() + }) + + it(`back-to-back opposite reorders settle to the correct final order`, () => { + const { collection: source, write } = manualCollection(`back-to-back`, [ + seed[0]!, + seed[1]!, + ]) + const live = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ s: source }) + .orderBy(({ s }) => s.value) + .select(({ s }) => ({ id: s.id })), + }) + expect(idsOf(live)).toEqual([`b`, `a`]) // a=2, b=1 + + let emitted = 0 + const sub = live.subscribeChanges(() => { + emitted++ + }) + + // move a to the front (2 -> 0) + write(`update`, { id: `a`, name: `Alice`, value: 0 }) + expect(idsOf(live)).toEqual([`a`, `b`]) + + // push a back behind b (0 -> 5) + write(`update`, { id: `a`, name: `Alice`, value: 5 }) + expect(idsOf(live)).toEqual([`b`, `a`]) + + // each reorder is observed; no stale/missing final state + expect(emitted).toBeGreaterThanOrEqual(2) + sub.unsubscribe() + }) }) From 989cfb03068928354e1b31fa75fd67677b4b48e8 Mon Sep 17 00:00:00 2001 From: Anton Vozghrin Date: Mon, 22 Jun 2026 10:10:54 +0300 Subject: [PATCH 4/4] test(db): assert exact emit count per reorder (no double-emit) --- packages/db/tests/live-query-orderby-reorder.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/db/tests/live-query-orderby-reorder.test.ts b/packages/db/tests/live-query-orderby-reorder.test.ts index e72e3a1ca..d448d6405 100644 --- a/packages/db/tests/live-query-orderby-reorder.test.ts +++ b/packages/db/tests/live-query-orderby-reorder.test.ts @@ -253,13 +253,14 @@ describe(`live query orderBy: order-only reorder must emit a change`, () => { // move a to the front (2 -> 0) write(`update`, { id: `a`, name: `Alice`, value: 0 }) expect(idsOf(live)).toEqual([`a`, `b`]) + expect(emitted).toBe(1) // exactly one emit for the reorder, no double-emit // push a back behind b (0 -> 5) write(`update`, { id: `a`, name: `Alice`, value: 5 }) expect(idsOf(live)).toEqual([`b`, `a`]) - // each reorder is observed; no stale/missing final state - expect(emitted).toBeGreaterThanOrEqual(2) + // exactly one emit per reorder — catches duplicate-emission regressions + expect(emitted).toBe(2) sub.unsubscribe() }) })