Skip to content

SQLite persistence: initial-render fallback resolves on a fresh empty cache before the first sync snapshot #2279

Closed

Description

@Jose10go

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.mjs

The 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 initialRender property makes this script pass: before the remote snapshot, initial rendering remains pending; after the snapshot, the row appears.
  • Changing networkTimeoutMs from 0 to 50 still 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

  1. KyleAMathews commented on Oct 28, 2026

    @KyleAMathews
    Collaborator

    Thanks for the precise reproduction — your diagnosis is right for 0.4.12: startup marked persisted readiness ready once the restore finished, even when it returned no rows.

    This is fixed on main by #2168 and will ship in the next release (not yet published). A persisted Collection now has one status, and under network-first an empty restore stays loading until the sync adapter answers. I ported your script to the new API and confirmed this with the browser adapter: at networkTimeoutMs: 0 and 50, the query stays pending and loading with no data until markReady(), then shows the remote row.

    When you upgrade, note that #2168 removes preloadForInitialRender(), persistedStatus, and isPersistedReady. Use observer.preload() (or useLiveSuspenseQuery) and status instead. 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.

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