Repository navigation
Description
- I've validated the bug against the latest version of DB packages (
1.0.0-rc.1.2, same code onmainat1443dc92e)
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-setWindowShow 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
Fix client page reuse and isolate successful Suspense outputs
Fix client page reuse and isolate Suspense query outputs (#2348)
KyleAMathews commentedon Nov 6, 2026
🎉 The fix for this issue has been released!
Fixing PRs: #2348
Backfilled notification for published release candidates.