Repository navigation
Description
Description
With persistedCollectionOptions and initialRender: { strategy: 'network-first', networkTimeoutMs: 0 }, a collection with a brand-new SQLite database releases preloadForInitialRender() before its sync adapter has supplied any data or called markReady().
At that point, the source is still loading and the live query returns []. When the first remote snapshot arrives, the query contains the expected rows. In our application this produces a brief empty state on first load or navigation to uncached data through useLiveSuspenseQuery.
The reproduction below isolates the observer/persistence behavior without React, Electric, Next.js, application wrappers, or an external server. It uses the actual browser persistence adapter with Node's in-memory SQLite as its database implementation. It does not exercise browser OPFS workers or multiple tabs.
Version scope: reproduced on the exact versions below. I have not verified newer releases. The current SQLite persistence documentation describes an empty restore staying loading until the network answers, and appears to describe a newer readiness API. If this is already fixed, please point me to the first release containing the fix.
Environment
- Node.js
24.21.0, macOS @tanstack/db:0.15.2@tanstack/browser-db-sqlite-persistence:0.2.37@tanstack/db-sqlite-persistence-core:0.4.12- Eager sync; a fresh in-memory database on every run
Minimal reproduction
In an empty directory, create these two files. Node 24 is required for the built-in SQLite API used here.
package.json:
{
"name": "tanstack-db-empty-restore-repro",
"private": true,
"type": "module",
"packageManager": "yarn@4.5.0",
"dependencies": {
"@tanstack/db": "0.15.2",
"@tanstack/browser-db-sqlite-persistence": "0.2.37",
"@tanstack/db-sqlite-persistence-core": "0.4.12",
"@journeyapps/wa-sqlite": "1.7.2"
},
"devDependencies": {
"typescript": "7.0.2"
}
}repro.mjs:
import assert from 'node:assert/strict';
import {DatabaseSync} from 'node:sqlite';
import {setTimeout as delay} from 'node:timers/promises';
import {createCollection, createLiveQueryCollection, createLiveQueryObserver} from '@tanstack/db';
import {createBrowserWASQLitePersistence, persistedCollectionOptions} from '@tanstack/browser-db-sqlite-persistence';
// A brand-new database: no previous snapshot, including no known-empty snapshot.
const sqlite = new DatabaseSync(':memory:');
const persistence = createBrowserWASQLitePersistence({
database: {
execute: async (sql, params = []) => sqlite.prepare(sql).all(...params),
},
});
const started = Promise.withResolvers();
const source = createCollection(persistedCollectionOptions({
id: 'todos',
getKey: row => row.id,
syncMode: 'eager',
startSync: false,
persistence,
schemaVersion: 1,
initialRender: {strategy: 'network-first', networkTimeoutMs: 0},
sync: {
// Model a pending remote request. No source data is sent until explicitly released.
sync: controls => { started.resolve(controls); },
},
}));
const query = createLiveQueryCollection(q => q.from({todo: source}));
const observer = createLiveQueryObserver(query);
const timeout = new AbortController();
let before;
try {
const initialRender = observer.preloadForInitialRender();
const controls = await started.promise;
before = await Promise.race([
initialRender.then(() => ({
initialRenderResolved: true,
sourceStatus: source.status,
data: observer.getSnapshot().data.map(({id, title}) => ({id, title})),
})),
delay(1000, undefined, {signal: timeout.signal}).then(() => ({
initialRenderResolved: false,
sourceStatus: source.status,
data: observer.getSnapshot().data.map(({id, title}) => ({id, title})),
})),
]);
console.log('Before remote snapshot:', before);
controls.begin();
controls.write({type: 'insert', value: {id: 1, title: 'Remote todo'}});
await controls.commit();
controls.markReady();
await observer.preload();
console.log('After remote snapshot:', {
sourceStatus: source.status,
data: observer.getSnapshot().data.map(({id, title}) => ({id, title})),
});
} finally {
timeout.abort();
observer.dispose();
await query.cleanup();
await source.cleanup();
sqlite.close();
}
assert.equal(before.initialRenderResolved, false,
'Initial render must not resolve from a cache that has never received a snapshot');Run:
yarn install
yarn node repro.mjsThe script deliberately exits with an assertion failure on the affected versions. It cleans up the observer, collections, and SQLite connection before asserting.
The one-second observation window only bounds the check. The remote snapshot is withheld explicitly until after the initial-render observation; it cannot arrive early because of network timing.
Actual result
Before remote snapshot: { initialRenderResolved: true, sourceStatus: 'loading', data: [] }
After remote snapshot: { sourceStatus: 'ready', data: [ { id: 1, title: 'Remote todo' } ] }
AssertionError [ERR_ASSERTION]: Initial render must not resolve from a cache that has never received a snapshot
true !== false
A successful read of an empty, never-populated cache is sufficient to release initial rendering.
Expected result
A cache that has never received a snapshot should not release initial rendering as an empty result. The query should remain pending until the sync adapter supplies its first snapshot and marks the source ready. In this reproduction, initialRenderResolved should be false before the manually supplied snapshot.
An actually empty server response is a separate case: once the adapter confirms that snapshot and calls markReady(), rendering an empty list is correct. Likewise, valid restored data should still be eligible for the configured fallback.
Controls verified
- Removing the
initialRenderproperty makes this script pass: before the remote snapshot, initial rendering remains pending; after the snapshot, the row appears. - Changing
networkTimeoutMsfrom0to50still reproduces the failure once the deadline expires while the remote snapshot remains pending. This is not specific to a zero-millisecond timeout. - The reproduction imports TanStack packages directly and contains no application workaround.
Possible cause
In the installed @tanstack/db-sqlite-persistence-core@0.4.12, PersistedCollectionRuntime.ensureStarted() in src/persisted.ts sets the persisted-readiness tracker to ready after startup restoration finishes. That transition does not require restored rows or evidence of a previously completed source snapshot. The live-query observer can then use this successful-but-empty restore to satisfy the initial-render fallback while source readiness is still pending.
This appears to conflate completion of a storage read with availability of data suitable for initial rendering. The report is about that observable behavior; it does not require a particular internal fix.
Activity
KyleAMathews commentedon Oct 28, 2026
Thanks for the precise reproduction — your diagnosis is right for 0.4.12: startup marked persisted readiness
readyonce the restore finished, even when it returned no rows.This is fixed on
mainby #2168 and will ship in the next release (not yet published). A persisted Collection now has onestatus, and undernetwork-firstan empty restore staysloadinguntil the sync adapter answers. I ported your script to the new API and confirmed this with the browser adapter: atnetworkTimeoutMs: 0and50, the query stays pending andloadingwith no data untilmarkReady(), then shows the remote row.When you upgrade, note that #2168 removes
preloadForInitialRender(),persistedStatus, andisPersistedReady. Useobserver.preload()(oruseLiveSuspenseQuery) andstatusinstead. The changeset has a migration table.A related trade-off: a cache that last synced to zero rows also waits for the network, because the restore can't tell it from one that never synced. #2284 tracks that.