Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/free-rivers-like.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db-sqlite-persistence-core': patch
---

fix: persisted preload() hangs when upstream sync never calls markReady
42 changes: 33 additions & 9 deletions packages/db-sqlite-persistence-core/src/persisted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {}

Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -2540,18 +2560,22 @@ function createLoopbackSyncConfig<
params.collection as Collection<T, TKey, PersistedCollectionUtils>,
)

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()
}
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return {
cleanup: () => {
cleanedUp = true
runtime.cleanup()
runtime.clearSyncControls()
},
Expand Down
41 changes: 41 additions & 0 deletions packages/db-sqlite-persistence-core/tests/persisted.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Todo, string> = {
sync: (_params) => {
// deliberately never call params.markReady() - simulates offlineFirst paused query
return { cleanup: () => {} }
},
}

const collection = createCollection(
persistedCollectionOptions<Todo, string>({
id: `persisted-offline-preload`,
getKey: (item) => item.id,
sync: neverReadySync,
persistence: {
adapter,
},
}),
)

const timeoutMs = 2000
const timeoutError = new Promise<never>((_, 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`, () => {
Expand Down