Skip to content

fix(db): reject preload() promise when collection errors so ErrorBoundary is reached - #1346

Closed
sleitor wants to merge 2 commits into
TanStack:mainfrom
sleitor:fix-1343
Closed

sleitor wants to merge 2 commits into
TanStack:mainfrom
sleitor:fix-1343

Conversation

@sleitor

@sleitor sleitor commented Mar 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #1343

useLiveSuspenseQuery was not propagating collection errors to <ErrorBoundary> because preload() would hang forever when the collection transitioned to the error state.

Root Cause

In packages/db/src/collection/sync.ts, preload() registers an onFirstReady callback that resolves the promise when the collection becomes ready. However, if the collection transitions to error while the promise is pending (e.g. because queryFn throws), the promise was never settled — neither resolved nor rejected. This left any <Suspense> boundary in a permanently suspended state and prevented the error from bubbling to an <ErrorBoundary>.

Fix

Two changes:

1. packages/db/src/collection/sync.ts

Subscribe to status:change events inside preload(). If the collection enters the error state while the promise is pending, reject the promise immediately with a CollectionIsInErrorStateError.

2. packages/react-db/src/useLiveSuspenseQuery.ts

Re-throw the actual error from collection.utils?.lastError (available via query-db-collection) instead of a generic fallback message, so <ErrorBoundary> receives the original error object.

Testing

All existing tests pass (@tanstack/db and @tanstack/react-db). The bug is also repro-able with the StackBlitz sandbox in the issue — after this fix the <ErrorBoundary> catches the error and renders its fallback.

…o ErrorBoundary

When a collection transitions to the 'error' state while preload() is pending,
the promise now rejects instead of hanging forever. This allows useLiveSuspenseQuery
to throw the error during render so it reaches the nearest <ErrorBoundary>.

Also re-throws the actual error from collection.utils?.lastError in
useLiveSuspenseQuery instead of a generic fallback message.

Fixes TanStack#1343
@changeset-bot

changeset-bot Bot commented Mar 9, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 673d99b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@tanstack/db Patch
@tanstack/react-db Patch
@tanstack/angular-db Patch
@tanstack/electric-db-collection Patch
@tanstack/offline-transactions Patch
@tanstack/powersync-db-collection Patch
@tanstack/query-db-collection Patch
@tanstack/rxdb-db-collection Patch
@tanstack/solid-db Patch
@tanstack/svelte-db Patch
@tanstack/trailbase-db-collection Patch
@tanstack/vue-db Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Mar 9, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1346

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1346

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1346

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1346

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1346

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1346

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1346

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1346

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1346

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1346

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1346

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1346

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1346

commit: 402ed65

@samwillis samwillis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for looking into this @sleitor

I asked GPT5.2 for a review, its findings are below. I think I agree with it we need to be sure that we surface the original error:


I found one substantive issue and one testing gap.

  1. packages/db/src/collection/sync.ts: preload() now loses the original synchronous startup error.

The new status:change listener rejects the pending preload promise with CollectionIsInErrorStateError as soon as the collection enters error. That works for the async transition this PR is targeting, but it also changes behavior when startSync() throws synchronously.

Today the sequence is:

  • preload() installs the status:change listener
  • preload() calls startSync()
  • startSync() catches the original error, sets status to error, then rethrows
  • setting status to error triggers the new listener first
  • the promise becomes rejected with CollectionIsInErrorStateError
  • the original thrown error no longer wins because the promise is already settled

I verified this locally with a small repro: a collection whose sync.sync() throws new Error('boom') now causes await collection.preload() to reject with CollectionIsInErrorStateError instead of the original Error('boom').

That also means the React side still won’t always surface the original error object to the nearest ErrorBoundary. In packages/react-db/src/useLiveSuspenseQuery.ts, the new logic only rethrows the real error when collection.utils?.lastError exists. For plain collections, this path still falls back to a generic error message.

Suggestion:

  • In preload(), make sure synchronous startSync() failures reject with the original thrown error.
  • A straightforward way to do that is to centralize resolve/reject/unsubscribe in a single settle helper, track whether preload() is currently inside the synchronous startSync() call, and ignore the status:change -> error listener during that window.
  • Longer term, if the goal is for useLiveSuspenseQuery to always throw the real initial-load error, that error probably needs to live on core collection/lifecycle state rather than only on query-db-collection utilities.
  1. packages/react-db/tests/useLiveSuspenseQuery.test.tsx: the exact regression path still isn’t covered.

I ran the targeted React test suite and it passed, but coverage still showed the changed error branch in useLiveSuspenseQuery as uncovered. I don’t see a test that drives a collection into initial error while suspended and asserts that the nearest ErrorBoundary receives the original error object.

Suggestion:

  • Add a core test that collection.preload() rejects with the original sync error when sync.sync() throws synchronously.
  • Add a core test for the async transition this PR is fixing: pending preload() rejects once the collection later enters error.
  • Add a React test that renders useLiveSuspenseQuery under Suspense + ErrorBoundary, triggers an initial load failure, and asserts the boundary receives the original error object rather than a generic fallback.

The fix direction in the PR makes sense, but I think preserving original error identity for synchronous startup failures is important before this merges.

@sleitor

sleitor commented Apr 7, 2026

Copy link
Copy Markdown
Contributor Author

👋 Gentle ping — just checking if this is still on the radar for review. Happy to address any feedback or rebase if needed!

@sleitor

sleitor commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review @samwillis (and GPT-5.2 for the analysis) — this is a solid catch. You're right that the synchronous startSync() throw path currently loses the original error identity to the new status:change listener, and that useLiveSuspenseQuery still falls back to a generic error for plain collections without utils.lastError.

I'll rework preload() to centralize resolve/reject/unsubscribe in a single settle helper, guard the status:change -> error listener while inside the synchronous startSync() call so the originally thrown error always wins, and add the three tests you outlined (core sync-throw regression, core async-transition regression, and the React Suspense+ErrorBoundary test). Will push an update shortly.

@KyleAMathews

Copy link
Copy Markdown
Collaborator

Thanks @sleitor for the work here. The core preload/error-propagation behavior was independently fixed by #1751 (c521b5d). This branch is now conflicting and its listener changes can regress synchronous error identity, so it should not be merged. Closing as superseded; remaining canonical error-identity work belongs under #672.

@sleitor
sleitor deleted the fix-1343 branch September 15, 2026 17:26
@sleitor

sleitor commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

thanks for clarification @KyleAMathews. That is makes sence.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

useLiveSuspenseQuery doesn't work with ErrorBoundary

3 participants