diff --git a/.changeset/free-rivers-like.md b/.changeset/free-rivers-like.md new file mode 100644 index 0000000000..82aa03d8fa --- /dev/null +++ b/.changeset/free-rivers-like.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db-sqlite-persistence-core': patch +--- + +fix: persisted preload() hangs when upstream sync never calls markReady diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 12cf8319c3..d4c35c8807 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -828,7 +828,7 @@ class PersistedCollectionRuntime< private readonly mode: PersistedMode, private readonly collectionId: string, private readonly persistence: PersistedResolvedPersistence, - private readonly syncMode: `eager` | `on-demand`, + readonly syncMode: `eager` | `on-demand`, private readonly dbName: string, ) {} @@ -2245,15 +2245,16 @@ function createWrappedSyncConfig< ...params, markReady: () => { void (fullStartPromise ?? runtime.ensureStarted()) - .then(() => { - params.markReady() - }) .catch((error) => { console.warn( `Failed persisted sync startup before markReady:`, error, ) - params.markReady() + }) + .finally(() => { + if (!startupState.cleanedUp) { + params.markReady() + } }) }, begin: (options?: { immediate?: boolean }) => { @@ -2484,6 +2485,25 @@ function createWrappedSyncConfig< let sourceResult: SyncConfigRes = {} const startupState = { cleanedUp: false } fullStartPromise = runtime.ensureStarted() + + // Mark ready after SQLite hydration so preload() resolves from local data + // even if the upstream never calls markReady() (e.g. query paused offline). + // Skipped in on-demand mode -- no rows load at startup, so the upstream sync + // owns readiness there. On failure, mark ready to avoid blocking consumers. + void fullStartPromise.then( + () => { + if (!startupState.cleanedUp && runtime.syncMode !== `on-demand`) { + params.markReady() + } + }, + (error) => { + console.warn(`Failed persisted sync startup:`, error) + if (!startupState.cleanedUp) { + params.markReady() + } + }, + ) + const sourceResultPromise = (async () => { await runtime.ensureStartupMetadataLoaded() @@ -2540,18 +2560,22 @@ function createLoopbackSyncConfig< params.collection as Collection, ) + let cleanedUp = false + void runtime .ensureStarted() - .then(() => { - params.markReady() - }) .catch((error) => { console.warn(`Failed persisted loopback startup:`, error) - params.markReady() + }) + .finally(() => { + if (!cleanedUp) { + params.markReady() + } }) return { cleanup: () => { + cleanedUp = true runtime.cleanup() runtime.clearSyncControls() }, diff --git a/packages/db-sqlite-persistence-core/tests/persisted.test.ts b/packages/db-sqlite-persistence-core/tests/persisted.test.ts index 57c11d8442..9b0d5b4397 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted.test.ts @@ -1568,6 +1568,47 @@ describe(`persistedCollectionOptions`, () => { title: `Updated`, }) }) + + it(`preload resolves from local SQLite data when upstream sync never calls markReady`, async () => { + // Regression test: collection.preload() used to hang forever when the upstream + // sync never called markReady() (e.g. TanStack Query offlineFirst pausing a + // query). The fix fires params.markReady() after ensureStarted() so local + // SQLite data is enough to unblock preload(). + const adapter = createRecordingAdapter([{ id: `1`, title: `Offline Todo` }]) + + const neverReadySync: SyncConfig = { + sync: (_params) => { + // deliberately never call params.markReady() - simulates offlineFirst paused query + return { cleanup: () => {} } + }, + } + + const collection = createCollection( + persistedCollectionOptions({ + id: `persisted-offline-preload`, + getKey: (item) => item.id, + sync: neverReadySync, + persistence: { + adapter, + }, + }), + ) + + const timeoutMs = 2000 + const timeoutError = new Promise((_, reject) => + setTimeout( + () => reject(new Error(`preload timed out after ${timeoutMs}ms`)), + timeoutMs, + ), + ) + + await Promise.race([collection.preload(), timeoutError]) + + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + title: `Offline Todo`, + }) + }) }) describe(`persisted key and identifier helpers`, () => {