From 00992108628daeccc39e9abf215586894a9300b7 Mon Sep 17 00:00:00 2001 From: Jordan Phillips Date: Thu, 25 Jun 2026 15:26:04 +1000 Subject: [PATCH 1/3] fix: persisted preload() hangs when upstream sync never calls markReady --- .../src/persisted.ts | 35 ++++++++++++---- .../tests/persisted.test.ts | 41 +++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 12cf8319c3..66d1066bc2 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() @@ -2542,11 +2562,10 @@ function createLoopbackSyncConfig< void runtime .ensureStarted() - .then(() => { - params.markReady() - }) .catch((error) => { console.warn(`Failed persisted loopback startup:`, error) + }) + .finally(() => { params.markReady() }) 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`, () => { From e17122d13b216dc9384e88f6e3b3203e4c8f61e1 Mon Sep 17 00:00:00 2001 From: Jordan Phillips Date: Thu, 25 Jun 2026 15:28:25 +1000 Subject: [PATCH 2/3] chore: changeset --- .changeset/free-rivers-like.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/free-rivers-like.md 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 From 6bb2bfd067bdcdf3fb2292b2edb401ee5123dad1 Mon Sep 17 00:00:00 2001 From: Jordan Phillips Date: Thu, 25 Jun 2026 15:43:13 +1000 Subject: [PATCH 3/3] fix: add cleanup guard to params.markReady() call --- packages/db-sqlite-persistence-core/src/persisted.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 66d1066bc2..d4c35c8807 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -2560,17 +2560,22 @@ function createLoopbackSyncConfig< params.collection as Collection, ) + let cleanedUp = false + void runtime .ensureStarted() .catch((error) => { console.warn(`Failed persisted loopback startup:`, error) }) .finally(() => { - params.markReady() + if (!cleanedUp) { + params.markReady() + } }) return { cleanup: () => { + cleanedUp = true runtime.cleanup() runtime.clearSyncControls() },