Skip to content

useLiveQuery still rebuilds on every page change under a DbClient (#2316 window reuse is skipped) #2345

Closed

Description

@Jaime02
  • I've validated the bug against the latest version of DB packages (1.0.0-rc.1.2, same code on main at 1443dc92e)

Follow-up to #2291 / #2316.

Describe the bug

#2316 keeps the live query Collection when only .limit() / .offset() change, but useLiveQuery only hands it the previous Collection when there is no DbClient:

// packages/react-db/src/useLiveQuery.ts
previousCollection:
  forSuspense || dbClient ? undefined : instance.collection,

So under a DbProvider every page change still rebuilds the whole query. That is every TanStack Start app using routerWithDbClient, which provides a DbClient to the whole tree. A query can't opt out: the hook uses config.client ?? contextDbClient, and descriptor sources need a DbClient anyway.

In our app (TanStack Start + Electric) this leaves six paginated tables rebuilding on each page change. For the most expensive one we kept the setWindow layout-effect workaround that #2316 was meant to replace.

To Reproduce

The #2291 benchmark in a React tree (happy-dom): 5,000 orders, 20,000 items, a filter plus a grouped sum joined to every order, pages of 20, 10 page changes. Bun 1.4.0, React 19.3.1, @tanstack/react-db 1.0.0-rc.1.2, NODE_ENV=production, 3 runs.

bun add react@19.3.1 react-dom@19.3.1 @tanstack/react-db@1.0.0-rc.1.2 @happy-dom/global-registrator
NODE_ENV=production bun bench.mjs no-client
NODE_ENV=production bun bench.mjs client
NODE_ENV=production bun bench.mjs client-setWindow
Show bench.mjs
// bun bench.mjs <no-client|client|client-setWindow>
import { GlobalRegistrator } from "@happy-dom/global-registrator";

const { setTimeout, clearTimeout } = globalThis;
GlobalRegistrator.register();
// happy-dom timers have no unref(), which the collection cleanup queue calls
Object.assign(globalThis, { setTimeout, clearTimeout });

// Imported after happy-dom so react-db sees a window and commits window moves in a layout effect
const { createElement, useLayoutEffect } = await import("react");
const { createRoot } = await import("react-dom/client");
const { DbClient, DbProvider, useLiveQuery, collectionOptions, createCollection, localOnlyCollectionOptions, eq, sum } =
  await import("@tanstack/react-db");

const mode = process.argv[2] ?? "no-client";
const withClient = mode !== "no-client";
const pageInKey = mode !== "client-setWindow";
const PAGE_SIZE = 20;

const orders = Array.from({ length: 5000 }, (_, i) => ({ id: i + 1, status: i % 2 ? "closed" : "open" }));
const items = Array.from({ length: 20000 }, (_, i) => ({ id: i + 1, orderId: (i % 5000) + 1, amount: (i % 97) + 1 }));
const openIds = orders.filter((order) => order.status === "open").map((order) => order.id);

function source(id, initialData) {
  const config = () => localOnlyCollectionOptions({ id, getKey: (row) => row.id, initialData });
  return withClient ? collectionOptions(id, config) : createCollection(config());
}
const ordersSource = source("orders", orders);
const itemsSource = source("items", items);

let waiter;
const collections = new Set();

function Table({ page }) {
  const offset = page * PAGE_SIZE;
  const { data, collection } = useLiveQuery({
    queryKey: pageInKey ? ["orders", page] : ["orders"],
    query: (q) => {
      const totals = q
        .from({ item: itemsSource })
        .groupBy(({ item }) => item.orderId)
        .select(({ item }) => ({ orderId: item.orderId, total: sum(item.amount) }));
      return q
        .from({ order: ordersSource })
        .where(({ order }) => eq(order.status, "open"))
        .leftJoin({ totals }, ({ order, totals }) => eq(order.id, totals.orderId))
        .orderBy(({ order }) => order.id)
        .limit(PAGE_SIZE)
        .offset(offset)
        .select(({ order, totals }) => ({ id: order.id, total: totals.total }));
    },
  });

  // Workaround: the page is left out of the queryKey and the window is moved before paint
  useLayoutEffect(() => {
    if (pageInKey || collection.utils.getWindow()?.offset === offset) return;
    void collection.utils.setWindow({ offset, limit: PAGE_SIZE });
  }, [collection, offset]);

  useLayoutEffect(() => {
    collections.add(collection);
    if (waiter && data.length === PAGE_SIZE && data[0].id === waiter.firstId) {
      waiter.resolve();
      waiter = undefined;
    }
  });
  return null;
}

const client = withClient ? new DbClient() : undefined;
const root = createRoot(document.createElement("div"));

async function goTo(page) {
  const done = new Promise((resolve) => (waiter = { firstId: openIds[page * PAGE_SIZE], resolve }));
  const start = performance.now();
  const table = createElement(Table, { page });
  root.render(client ? createElement(DbProvider, { client }, table) : table);
  await done;
  return performance.now() - start;
}

const first = await goTo(0);
const times = [];
for (const page of [1, 2, 3, 7, 4, 9, 10, 3, 12, 0]) times.push(await goTo(page));
times.sort((a, b) => a - b);
console.log(
  `${mode}: first page ${first.toFixed(0)} ms, page change median ${times[5].toFixed(1)} ms ` +
    `(min ${times[0].toFixed(1)}, max ${times[9].toFixed(1)}), live query collections: ${collections.size}`,
);
process.exit(0);
Mode DbClient Page Page change (median) Live query Collections
no-client no in queryKey 5.5–5.7 ms 1
client yes in queryKey 113–120 ms 11
client-setWindow yes setWindow in a layout effect 5.6–6.5 ms 1

The window moves fine under a DbClient when setWindow is called by hand, so only the adapter's reuse path is missing.

Expected behavior

client behaves like no-client: one Collection, with page changes moved through setWindow.

Additional context

I assume the exclusion protects the DbClient's live-query registry, whose hash includes limit / offset for a keyed query (getLiveQueryHash) and which preload, dehydrate and hydrate match against. If so, could a window move re-register the Collection under the new hash instead of rebuilding it?

Activity

  1. KyleAMathews commented on Nov 6, 2026

    @KyleAMathews
    Collaborator

    🎉 The fix for this issue has been released!

    Fixing PRs: #2348

    Backfilled notification for published release candidates.

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