Skip to content

useLiveQuery: move an offset/limit page window without rebuilding the live query #2291

Open

Description

@Jaime02

Problem

With numbered pagination (jump to any page, page kept in the URL), the page has to be part of the query identity. So every page change creates a new live query collection and reruns the whole pipeline.

utils.setWindow() moves the window without rebuilding the query, but useLiveQuery can't drive it. It has to be called from a layout effect, so the hook first renders the old page, and the window is split between .limit().offset() and the effect (the split #1623 warns about).

useLiveInfiniteQuery only grows forward, so it can't jump to a page.

// Today: every page rebuilds everything
useLiveQuery({ queryKey: ['orders', filters, page], query: (q) => expensive(q).orderBy(...).limit(20).offset(page * 20) })

// Workaround
useLayoutEffect(() => { void collection.utils.setWindow({ offset: page * 20, limit: 20 }) }, [page])

Proposal

useLiveQuery({
  queryKey: ['orders', filters],
  query: (q) => expensive(q).orderBy(...),
  window: { offset: page * 20, limit: 20 }, // a change here only calls setWindow on the same collection
})

Or a page mode in the shared window binding from #2235.

Benchmark

Setup: 5,000 orders, 20,000 items, a filter plus a grouped sum joined to every order, pages of 20, 10 page changes. @tanstack/db 0.15.1, Bun, 3 runs.

First page Each next page
Rebuild per page (bun bench.ts rebuild) 392–456 ms 221–244 ms
setWindow (bun bench.ts setWindow) 388–480 ms 0.8–1.1 ms

The benchmark has no includes. With on-demand materialize includes, setWindow publishes the new rows before their children load, so they fill in over the next renders (in our app: rows at about 300 ms, complete at about 600 ms).

bench.ts
// bun bench.ts rebuild | setWindow | chained | chainedIndex
import { BasicIndex, createCollection, createLiveQueryCollection, eq, localOnlyCollectionOptions, sum } from '@tanstack/db'

const indexed = { autoIndex: 'eager', defaultIndexType: BasicIndex } as const
const orders = createCollection(localOnlyCollectionOptions({ ...indexed, getKey: (o: any) => o.id,
  initialData: Array.from({ length: 5000 }, (_, i) => ({ id: i, status: i % 3 ? 'open' : 'closed' })) }))
const items = createCollection(localOnlyCollectionOptions({ ...indexed, getKey: (i: any) => i.id,
  initialData: Array.from({ length: 20000 }, (_, i) => ({ id: i, orderId: i % 5000, amount: (i * 7919) % 1000 })) }))

// Expensive part: filter + grouped aggregate joined to every order
const filtered = (q: any) => q.from({ order: orders })
  .join({ t: q.from({ item: items }).groupBy(({ item }: any) => item.orderId)
    .select(({ item }: any) => ({ orderId: item.orderId, total: sum(item.amount) })) },
    ({ order, t }: any) => eq(order.id, t.orderId))
  .where(({ order }: any) => eq(order.status, 'open'))
  .select(({ order, t }: any) => ({ id: order.id, total: t.total }))
const page = (q: any, from: any, offset: number) => q.from({ row: from })
  .orderBy(({ row }: any) => row.total, 'desc').orderBy(({ row }: any) => row.id).limit(20).offset(offset)
const live = async (query: any) => {
  const c = createLiveQueryCollection({ startSync: true, query })
  await c.toArrayWhenReady()
  return c as any
}

const mode = process.argv[2]
let warnings = 0
console.warn = () => warnings++
const times: Array<number> = []
const timed = async (fn: () => Promise<unknown>) => { const t = performance.now(); await fn(); times.push(performance.now() - t) }

if (mode === 'rebuild') {
  // What useLiveQuery does when the page is part of the queryKey
  for (let p = 0; p < 10; p++) await timed(async () => (await live((q: any) => page(q, filtered(q), p * 20))).cleanup())
} else if (mode === 'setWindow') {
  let c: any
  await timed(async () => { c = await live((q: any) => page(q, filtered(q), 0)) })
  for (let p = 1; p < 10; p++) await timed(async () => { const r = c.utils.setWindow({ offset: p * 20, limit: 20 }); if (r !== true) await r })
} else {
  // Base cached as its own live query collection, the page query over it is rebuilt per page
  let base: any
  await timed(async () => {
    base = await live(filtered)
    if (mode === 'chainedIndex') base.createIndex((row: any) => row.total, { indexType: BasicIndex })
  })
  for (let p = 0; p < 10; p++) await timed(async () => (await live((q: any) => page(q, base, p * 20))).cleanup())
}
const next = times.slice(-9)
console.log(mode, `first ${times[0]!.toFixed(1)} ms`, `next pages avg ${(next.reduce((a, b) => a + b) / next.length).toFixed(1)} ms`, `index warnings ${warnings}`)

Related: #1623, #2235, #2137

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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