Skip to content

Instantly share code, notes, and snippets.

@MAST1999
Last active September 20, 2026 03:38
Show Gist options
  • Select an option

  • Save MAST1999/62526dceb542323d009d036e3bb8ed7e to your computer and use it in GitHub Desktop.

Select an option

Save MAST1999/62526dceb542323d009d036e3bb8ed7e to your computer and use it in GitHub Desktop.
Repro: TanStack DB persisted on-demand collection is never synchronously ready (loadSubset always async)

Repro: a persisted on-demand collection is never synchronously ready

@tanstack/db@0.9.2 · @tanstack/db-sqlite-persistence-core@0.2.23 · @tanstack/react-db@0.4.1 (latest at time of writing)

npm install
npm run node      # the cause, no React, no browser
npm run browser   # the symptom, in real Chromium

What it shows

LoadSubsetFn is declared (options: LoadSubsetOptions) => true | Promise<void>. A bare true is how an implementation says "this subset is already loaded, there is nothing to await", and CollectionSyncManager.loadSubset branches on it: if (result instanceof Promise) { trackLoadPromise(result) … } return true.

createWrappedSyncConfig in db-sqlite-persistence-core returns loadSubset: async (options) => { … }. Being async, it is a Promise even when everything it awaits is already settled and the underlying sync answered true, so the true branch is unreachable for any persisted collection.

node-repro.mjs — the same collection options, with and without persistedCollectionOptions:

no persistence : ready (2 rows loaded)
persisted      : loading (2 rows loaded)

suspense.test.jsx — what that costs under useLiveSuspenseQuery. A component that suspends before it mounts loses its refs, so every retry builds a fresh live query, which is loading, which throws a fresh promise, which resolves, which retries:

✓ an unpersisted on-demand collection settles under Suspense    54ms
× the same collection with persistence never leaves its fallback
  → the boundary never released; the component re-suspended 13778 times

Notes

There is no SQLite here. memory-adapter.js is an in-memory PersistenceAdapter of the same shape as this repo's own createRecordingAdapter in packages/db-sqlite-persistence-core/tests/persisted.test.ts. The defect is in the sync wrapper that persistedCollectionOptions puts in front of any adapter, so the storage backend is irrelevant.

The browser run uses @vitest/browser + playwright against real Chromium, so the render loop is not a jsdom artefact.

/**
* A minimal in-memory `PersistenceAdapter`, the same shape as TanStack DB's own
* `createRecordingAdapter` in
* `packages/db-sqlite-persistence-core/tests/persisted.test.ts`.
*
* There is no SQLite here on purpose: the defect is in the sync wrapper that
* `persistedCollectionOptions` puts in front of *any* adapter, so the storage
* is irrelevant and leaving it out keeps the repro runnable in a browser.
*/
export const createMemoryAdapter = () => {
const rows = new Map();
const collectionMetadata = new Map();
const loadSubsetCalls = [];
const snapshot = () =>
[...rows.values()].map((value) => ({ key: value.id, value }));
return {
loadSubsetCalls,
loadSubset: (collectionId, options) => {
loadSubsetCalls.push({ collectionId, options });
return Promise.resolve(snapshot());
},
loadCollectionMetadata: () =>
Promise.resolve([...collectionMetadata.entries()].map(([key, value]) => ({ key, value }))),
scanRows: () => Promise.resolve(snapshot()),
applyCommittedTx: (_collectionId, tx) => {
if (tx.truncate) rows.clear();
for (const mutation of tx.mutations) {
if (mutation.type === `delete`) rows.delete(mutation.key);
else rows.set(mutation.key, mutation.value);
}
for (const mutation of tx.collectionMetadataMutations ?? []) {
if (mutation.type === `delete`) collectionMetadata.delete(mutation.key);
else collectionMetadata.set(mutation.key, mutation.value);
}
return Promise.resolve();
},
ensureIndex: () => Promise.resolve(),
markIndexRemoved: () => Promise.resolve(),
};
};
/**
* The same defect as `suspense.test.jsx`, without React or a browser — so the
* cause is visible on its own.
*
* npm install && node node-repro.mjs
*
* `LoadSubsetFn` is declared `(options) => true | Promise<void>`. A bare `true`
* is how a sync implementation says "this subset is already here". The mock
* sync below returns exactly that on a repeat call.
*
* Without persistence the second live query is `ready` at construction. Wrap
* the *same* options in `persistedCollectionOptions` and it is `loading`.
*/
import { createCollection, createLiveQueryCollection, eq } from "@tanstack/db";
import { persistedCollectionOptions } from "@tanstack/db-sqlite-persistence-core";
import { createMemoryAdapter } from "./memory-adapter.js";
const ROWS = [
{ id: `1`, listId: `a`, text: `first` },
{ id: `2`, listId: `a`, text: `second` },
];
const onDemandSync = () => {
const loaded = new Set();
return {
syncMode: `on-demand`,
getKey: (row) => row.id,
sync: {
sync: ({ begin, write, commit, markReady }) => {
begin();
commit();
markReady();
return {
loadSubset: (options) => {
const key = JSON.stringify(options.where ?? null);
if (loaded.has(key)) return true; // already loaded — nothing to await
loaded.add(key);
begin();
for (const row of ROWS) write({ type: `insert`, value: row });
commit();
return true;
},
};
},
},
};
};
const build = (persisted) => {
const options = { id: persisted ? `persisted` : `plain`, ...onDemandSync() };
return createCollection(
persisted
? persistedCollectionOptions({
...options,
persistence: { adapter: createMemoryAdapter() },
})
: options,
);
};
const query = (collection) =>
createLiveQueryCollection({
startSync: true,
query: (q) => q.from({ row: collection }).where(({ row }) => eq(row.listId, `a`)),
});
/** Load the subset, then build an identical query and read its status at once. */
const statusOfSecondQuery = async (collection) => {
const first = query(collection);
await first.preload();
const second = query(collection);
// No await between construction and this read.
return { status: second.status, rows: [...first.values()].length };
};
const plain = await statusOfSecondQuery(build(false));
const persisted = await statusOfSecondQuery(build(true));
console.log(`no persistence : ${plain.status} (${plain.rows} rows loaded)`);
console.log(`persisted : ${persisted.status} (${persisted.rows} rows loaded)`);
console.log();
const ok = plain.status === `ready` && persisted.status === `ready`;
console.log(
ok
? `PASS - both ready synchronously`
: `FAIL - persisted is "${persisted.status}" where unpersisted is "${plain.status}",\n` +
` though both hold the same rows. The synchronous \`true\` was swallowed\n` +
` by createWrappedSyncConfig's \`loadSubset: async (options) => ...\`.`,
);
process.exit(ok ? 0 : 1);
{
"name": "tanstack-db-persisted-ondemand-repro",
"private": true,
"type": "module",
"scripts": {
"node": "node node-repro.mjs",
"browser": "vitest run"
},
"dependencies": {
"@tanstack/db": "0.9.0",
"@tanstack/db-sqlite-persistence-core": "0.2.21",
"@tanstack/react-db": "0.3.8",
"react": "^19",
"react-dom": "^19"
},
"devDependencies": {
"@vitest/browser": "^3",
"playwright": "^1.56",
"vitest": "^3"
}
}
/**
* A persisted `on-demand` collection never becomes synchronously ready, so
* `useLiveSuspenseQuery` re-suspends without limit.
*
* npx vitest run # real Chromium, via @vitest/browser + playwright
*
* No SQLite, no Electric, no OPFS — an in-memory `PersistenceAdapter` of the
* same shape as this repo's own `createRecordingAdapter`. The only difference
* between the two cases below is `persistedCollectionOptions`.
*/
import { createCollection, eq } from "@tanstack/db";
import { persistedCollectionOptions } from "@tanstack/db-sqlite-persistence-core";
import { useLiveSuspenseQuery } from "@tanstack/react-db";
import { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { expect, test } from "vitest";
import { createMemoryAdapter } from "./memory-adapter.js";
const ROWS = [
{ id: `1`, listId: `a`, text: `first` },
{ id: `2`, listId: `a`, text: `second` },
];
/**
* An `on-demand` sync whose `loadSubset` answers a repeat request with the
* synchronous `true` that `LoadSubsetFn` is declared to allow:
* `(options) => true | Promise<void>`.
*/
const onDemandSync = () => {
const loaded = new Set();
return {
syncMode: `on-demand`,
getKey: (row) => row.id,
sync: {
sync: ({ begin, write, commit, markReady }) => {
begin();
commit();
markReady();
return {
loadSubset: (options) => {
const key = JSON.stringify(options.where ?? null);
if (loaded.has(key)) return true;
loaded.add(key);
begin();
for (const row of ROWS) write({ type: `insert`, value: row });
commit();
return true;
},
};
},
},
};
};
const build = (persisted) => {
const options = { id: persisted ? `persisted` : `plain`, ...onDemandSync() };
return createCollection(
persisted
? persistedCollectionOptions({
...options,
persistence: { adapter: createMemoryAdapter() },
})
: options,
);
};
/** Mounts the query under a boundary and reports what the browser painted. */
const mount = async (collection, budgetMs) => {
const renders = { count: 0 };
const List = () => {
renders.count++;
const { data } = useLiveSuspenseQuery({
query: (q) => q.from({ row: collection }).where(({ row }) => eq(row.listId, `a`)),
});
return <span data-testid="rows">rows:{data.length}</span>;
};
const host = document.createElement(`div`);
document.body.appendChild(host);
const root = createRoot(host);
root.render(
<StrictMode>
<Suspense fallback={<span>loading</span>}>
<List />
</Suspense>
</StrictMode>,
);
const deadline = Date.now() + budgetMs;
while (Date.now() < deadline && !host.textContent.startsWith(`rows:`)) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
const text = host.textContent;
root.unmount();
host.remove();
return { text, renders: renders.count };
};
test(`an unpersisted on-demand collection settles under Suspense`, async () => {
const result = await mount(build(false), 3_000);
expect(result.text).toBe(`rows:2`);
// It never suspends at all: the subset is already loaded and the live query
// is ready at construction.
expect(result.renders).toBeLessThan(10);
});
test(`the same collection with persistence never leaves its fallback`, async () => {
const result = await mount(build(true), 3_000);
// This is the bug. Expected: `rows:2`, a handful of renders.
expect(
{ text: result.text, renders: result.renders },
`the boundary never released; the component re-suspended ${result.renders} times`,
).toEqual({ text: `rows:2`, renders: result.renders });
expect(result.renders).toBeLessThan(10);
});
import { defineConfig } from "vitest/config";
export default defineConfig({
esbuild: { jsx: "automatic" },
test: {
include: ["*.test.jsx"],
browser: {
enabled: true,
provider: "playwright",
headless: true,
instances: [{ browser: "chromium" }],
},
},
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment