-
Notifications
You must be signed in to change notification settings - Fork 183
Open
Description
- I've validated the bug against the latest version of DB packages
Describe the bug
It's not clear what the interaction between useLiveSuspenseQuery and ErrorBoundary is supposed to look like. Currently, if the fetch function throws, it isnt caught by the ErrorBoundary
To Reproduce
Steps to reproduce the behavior:
https://stackblitz.com/edit/vitejs-vite-w4tkecqk?file=src%2FApp.tsx
Copy pasted snippet of code
import React, { Suspense, useState } from 'react';
import { createCollection, useLiveSuspenseQuery } from '@tanstack/react-db';
import { queryCollectionOptions } from '@tanstack/query-db-collection';
import { QueryClient } from '@tanstack/query-core';
import { ErrorBoundary } from 'react-error-boundary';
interface Todo {
id: string;
text: string;
completed: boolean;
createdAt: number;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
let mockDb: Todo[] = [
{
id: '1',
text: 'Learn TanStack DB',
completed: false,
createdAt: Date.now(),
},
];
const api = {
getTodos: async (): Promise<Todo[]> => {
await sleep(1000);
throw new Error('random error');
// Uncomment this for normal usage.
// return [...mockDb];
},
addTodo: async (todo: Todo): Promise<Todo> => {
await sleep(600);
mockDb.push(todo);
return todo;
},
updateTodo: async (id: string, updates: Partial<Todo>): Promise<Todo> => {
await sleep(800);
const index = mockDb.findIndex((t) => t.id === id);
mockDb[index] = { ...mockDb[index], ...updates };
return mockDb[index];
},
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
throwOnError: true,
},
},
});
const todoCollection = createCollection(
queryCollectionOptions({
queryClient: queryClient,
queryKey: ['todos'],
queryFn: () => api.getTodos(),
getKey: (todo: Todo) => todo.id,
onUpdate: async ({ transaction }) => {
// The transaction contains the modified data
const { modified } = transaction.mutations[0];
await api.updateTodo(modified.id, modified);
},
onInsert: async ({ transaction }) => {
// The transaction contains the modified data
const { modified } = transaction.mutations[0];
await api.addTodo(modified);
},
})
);
function TodoList() {
const [text, setText] = useState('');
const { data: todos = [] } = useLiveSuspenseQuery((q) =>
q
.from({ todo: todoCollection })
.orderBy(({ todo }) => todo.createdAt, 'desc')
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim()) return;
const newTodo: Todo = {
id: Math.random().toString(36).slice(2, 9),
text,
completed: false,
createdAt: Date.now(),
};
// collection.insert updates the UI state instantly
todoCollection.insert(newTodo);
setText('');
};
const toggleTodo = (todo: Todo) => {
todoCollection.update(todo.id, (draft) => {
draft.completed = !draft.completed;
});
};
return (
<div>
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add Todo</button>
</form>
<ul>
{todos.map((todo) => (
<li
key={todo.id}
onClick={() => toggleTodo(todo)}
style={{
cursor: 'pointer',
textDecoration: todo.completed ? 'line-through' : 'none',
}}
>
{todo.text} {todo.completed ? '✅' : '⭕'}
</li>
))}
</ul>
</div>
);
}
export default function App() {
return (
<div>
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<p>Loading...</p>}>
<TodoList />
</Suspense>
</ErrorBoundary>
</div>
);
}package.json
{
"name": "vite-react-typescript-starter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/query-db-collection": "^1.0.28",
"@tanstack/react-db": "^0.1.75",
"@tanstack/react-query": "^5.90.21",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-error-boundary": "^6.1.1"
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@types/node": "^24.11.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^9.39.3",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
}
}tl;dr
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<p>Loading...</p>}>
<TodoList />
</Suspense>
</ErrorBoundary>
// in TodoList
const { data: todos = [] } = useLiveSuspenseQuery((q) =>
q
.from({ todo: todoCollection })
.orderBy(({ todo }) => todo.createdAt, 'desc')
);
// in collection
const todoCollection = createCollection(
queryCollectionOptions({
queryClient: queryClient,
queryKey: ['todos'],
queryFn: () => api.getTodos(),
// api.getTodos()
const api = {
getTodos: async (): Promise<Todo[]> => {
await sleep(1000);
throw new Error('random error');
// Uncomment this for normal usage.
// return [...mockDb];
},Expected behavior
I expected the error to be caught by the error boundary.
Workaround
Throwing the collection error if encountered:
const { data:todos } = useLiveSuspenseQuery((q) => q.from({ todo: todoCollection }))
if (todoCollection.utils.isError) {
throw todoCollection.utils.lastError
}Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels