From 00992108628daeccc39e9abf215586894a9300b7 Mon Sep 17 00:00:00 2001 From: Jordan Phillips Date: Thu, 25 Jun 2026 15:26:04 +1000 Subject: [PATCH 1/4] 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/4] 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/4] 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() }, From 8e2fb3a4a42f5d49670ccaa8f5337cc31d0bfe38 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 13 Jul 2026 16:04:12 -0600 Subject: [PATCH 4/4] test(persistence): cover readiness lifecycle races --- .../src/persisted.ts | 22 +-- .../tests/persisted.test.ts | 156 +++++++++++++++++- 2 files changed, 158 insertions(+), 20 deletions(-) diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 6173529be7..abf3453fb3 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -2209,6 +2209,13 @@ function createWrappedSyncConfig< ...sourceSyncConfig, sync: (params) => { const transactionStack: Array> = [] + const startupState = { cleanedUp: false, readySignalled: false } + const signalReady = () => { + if (!startupState.cleanedUp && !startupState.readySignalled) { + startupState.readySignalled = true + params.markReady() + } + } const getOpenTransaction = () => transactionStack[transactionStack.length - 1] let fullStartPromise: Promise | null = null @@ -2251,11 +2258,7 @@ function createWrappedSyncConfig< error, ) }) - .finally(() => { - if (!startupState.cleanedUp) { - params.markReady() - } - }) + .finally(signalReady) }, begin: (options?: { immediate?: boolean }) => { const transaction: OpenSyncTransaction = { @@ -2495,7 +2498,6 @@ function createWrappedSyncConfig< } let sourceResult: SyncConfigRes = {} - const startupState = { cleanedUp: false } fullStartPromise = runtime.ensureStarted() // Mark ready after SQLite hydration so preload() resolves from local data @@ -2504,15 +2506,13 @@ function createWrappedSyncConfig< // owns readiness there. On failure, mark ready to avoid blocking consumers. void fullStartPromise.then( () => { - if (!startupState.cleanedUp && runtime.syncMode !== `on-demand`) { - params.markReady() + if (runtime.syncMode !== `on-demand`) { + signalReady() } }, (error) => { console.warn(`Failed persisted sync startup:`, error) - if (!startupState.cleanedUp) { - params.markReady() - } + signalReady() }, ) diff --git a/packages/db-sqlite-persistence-core/tests/persisted.test.ts b/packages/db-sqlite-persistence-core/tests/persisted.test.ts index edb90a3a77..e505ae6d84 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted.test.ts @@ -256,6 +256,16 @@ async function flushAsyncWork(delayMs: number = 0): Promise { await new Promise((resolve) => setTimeout(resolve, delayMs)) } +function deferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + describe(`persistedCollectionOptions`, () => { it(`provides a sync-absent loopback configuration with persisted utils`, async () => { const adapter = createRecordingAdapter() @@ -1666,21 +1676,149 @@ describe(`persistedCollectionOptions`, () => { }), ) - 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]) + await collection.preload() expect(stripVirtualProps(collection.get(`1`))).toEqual({ id: `1`, title: `Offline Todo`, }) }) + + it(`keeps on-demand readiness gated on the upstream`, async () => { + const upstreamStarted = deferred() + let markUpstreamReady: (() => void) | undefined + const collection = createCollection( + persistedCollectionOptions({ + id: `on-demand-readiness`, + syncMode: `on-demand`, + getKey: (item) => item.id, + sync: { + sync: ({ markReady }) => { + markUpstreamReady = markReady + upstreamStarted.resolve() + }, + }, + persistence: { adapter: createRecordingAdapter() }, + }), + ) + + collection.startSyncImmediate() + await upstreamStarted.promise + expect(collection.status).toBe(`loading`) + + markUpstreamReady?.() + await collection.stateWhenReady() + expect(collection.status).toBe(`ready`) + }) + + it(`cleanup before hydration prevents late readiness`, async () => { + const hydrationStarted = deferred() + const hydration = deferred>() + const adapter = createRecordingAdapter() + adapter.loadSubset = () => { + hydrationStarted.resolve() + return hydration.promise + } + const collection = createCollection( + persistedCollectionOptions({ + id: `cleanup-during-hydration`, + getKey: (item) => item.id, + sync: { sync: () => ({}) }, + persistence: { adapter }, + }), + ) + let readyEvents = 0 + collection.on(`status:ready`, () => readyEvents++) + + collection.startSyncImmediate() + await hydrationStarted.promise + await collection.cleanup() + hydration.resolve([]) + await hydration.promise + await Promise.resolve() + + expect(readyEvents).toBe(0) + }) + + it(`signals first readiness once when hydration and upstream race`, async () => { + const hydrationStarted = deferred() + const hydration = deferred>() + const adapter = createRecordingAdapter() + adapter.loadSubset = () => { + hydrationStarted.resolve() + return hydration.promise + } + let markUpstreamReady: (() => void) | undefined + const collection = createCollection( + persistedCollectionOptions({ + id: `readiness-race`, + getKey: (item) => item.id, + sync: { sync: ({ markReady }) => void (markUpstreamReady = markReady) }, + persistence: { adapter }, + }), + ) + let readyEvents = 0 + collection.on(`status:ready`, () => readyEvents++) + + collection.startSyncImmediate() + await hydrationStarted.promise + markUpstreamReady?.() + hydration.resolve([]) + await collection.stateWhenReady() + await Promise.resolve() + + expect(readyEvents).toBe(1) + }) + + it(`settles readiness when startup fails without upstream readiness`, async () => { + const adapter = createRecordingAdapter() + adapter.loadSubset = () => Promise.reject(new Error(`startup failed`)) + const collection = createCollection( + persistedCollectionOptions({ + id: `failed-startup-settles`, + getKey: (item) => item.id, + sync: { sync: () => ({}) }, + persistence: { adapter }, + }), + ) + + await collection.stateWhenReady() + expect(collection.status).toBe(`ready`) + }) + + it(`ignores upstream readiness and hydration callbacks after cleanup`, async () => { + const hydrationStarted = deferred() + const hydration = deferred>() + const adapter = createRecordingAdapter() + adapter.loadSubset = () => { + hydrationStarted.resolve() + return hydration.promise + } + let markUpstreamReady: (() => void) | undefined + const collection = createCollection( + persistedCollectionOptions({ + id: `late-callbacks-after-cleanup`, + getKey: (item) => item.id, + sync: { sync: ({ markReady }) => void (markUpstreamReady = markReady) }, + persistence: { adapter }, + }), + ) + let readyEvents = 0 + collection.on(`status:ready`, () => readyEvents++) + + collection.startSyncImmediate() + await hydrationStarted.promise + await collection.cleanup() + markUpstreamReady?.() + hydration.resolve([ + { key: `late`, value: { id: `late`, title: `Must not apply` } }, + ]) + await hydration.promise + await Promise.resolve() + + expect(readyEvents).toBe(0) + expect(collection.get(`late`)).toBeUndefined() + }) }) describe(`persisted key and identifier helpers`, () => {