Skip to content

Commit 0b70a12

Browse files
authored
fix: surface observable errors via status instead of re-throwing (#735)
In non-suspense mode (the default), errors from reactfire hooks are now returned via `status: 'error'` instead of being thrown unconditionally, which made `status: 'error'` unreachable in practice. In suspense mode errors are still re-thrown so a React Error Boundary can catch them. Breaking change, targeted at v5. Documented in docs/use.md (Error Handling) and docs/upgrade-guide.md (v4 to v5). Closes #535, closes #540.
1 parent aec5b83 commit 0b70a12

6 files changed

Lines changed: 156 additions & 6 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
CLAUDE.local*
12
.DS_Store
23
npm-debug.log
34

‎docs/upgrade-guide.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
1+
# Upgrade from ReactFire v4 to v5
2+
3+
ReactFire v5 contains breaking changes. This section lists them as they land; add an entry here in any PR that changes public behavior.
4+
5+
## Error handling behavior change
6+
7+
Previously, errors from any reactfire hook were thrown unconditionally, making `status: 'error'` unreachable in practice. In v5, error handling depends on the mode:
8+
9+
- **Non-suspense mode** (default, or `suspense: false`): errors are returned via `status: 'error'` so components can handle them locally.
10+
- **Suspense mode** (`suspense: true`): errors are re-thrown so a React Error Boundary can catch them. No change from prior behavior.
11+
12+
**If you rely on a React Error Boundary to catch Firebase errors in non-suspense mode**, you must add an explicit re-throw in your component:
13+
14+
```tsx
15+
const { status, error } = useStorageDownloadURL(ref);
16+
if (status === 'error') throw error; // re-throw to reach your Error Boundary
17+
```
18+
19+
**If you already check `status` before using `data`**, no change is needed.
20+
21+
Note: once an observable errors there is no automatic retry. The errored observable remains in the global cache under its `observableId`, so unmounting and remounting the same component rejoins the same errored state. Today the only workaround is to change the `observableId`. A proper retry mechanism is tracked in [#742](https://github.com/FirebaseExtended/reactfire/issues/742).
22+
23+
---
24+
125
# Upgrade from ReactFire v3 to v4
226

327
As announced in [Discussion 402](https://github.com/FirebaseExtended/reactfire/discussions/402), ReactFire v4 contains breaking changes. This guide details how to upgrade from v3 to v4.

‎docs/use.md‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
* [Initialize product SDKs and register them with ReactFire](#initialize-product-sdks-and-register-them-with-reactfire)
77
* [Connect to the Firebase Local Emulator Suite](#connect-to-the-firebase-local-emulator-suite)
88
* [Set up App Check](#set-up-app-check)
9+
- [Error Handling](#error-handling)
10+
* [Non-suspense mode (default)](#non-suspense-mode-default)
11+
* [Suspense mode](#suspense-mode)
12+
* [No automatic retry](#no-automatic-retry)
913
- [Auth](#auth)
1014
* [Display the current signed-in user](#display-the-current-signed-in-user)
1115
* [Only render a component if a user is signed in](#only-render-a-component-if-a-user-is-signed-in)
@@ -167,6 +171,51 @@ function FirebaseComponents({ children }) {
167171

168172
See the [App Check setup guide in the Firebase docs](https://firebase.google.com/docs/app-check/web/recaptcha-provider#project-setup) for more detailed instructions.
169173

174+
## Error Handling
175+
176+
ReactFire hooks report errors from the underlying Firebase observable, and how an error surfaces depends on whether suspense mode is enabled.
177+
178+
### Non-suspense mode (default)
179+
180+
By default (or with `suspense: false`), an error is returned through the hook's `status` so you can handle it where the data is used:
181+
182+
```tsx
183+
function CatImage() {
184+
const storage = useStorage();
185+
const catRef = ref(storage, 'cats/newspaper');
186+
187+
const { status, data: imageURL, error } = useStorageDownloadURL(catRef);
188+
189+
if (status === 'loading') {
190+
return <span>loading...</span>;
191+
}
192+
193+
if (status === 'error') {
194+
return <span>Error: {error.message}</span>;
195+
}
196+
197+
return <img src={imageURL} alt="cat reading the newspaper" />;
198+
}
199+
```
200+
201+
If a hook emits a value and then errors, `status` becomes `'error'` while `data` keeps its last emitted value.
202+
203+
### Suspense mode
204+
205+
When suspense is enabled (`<FirebaseAppProvider suspense={true}>`, or `suspense: true` on the hook), errors are thrown instead so the nearest React [Error Boundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) can catch them. In this mode the hook never returns `status: 'error'`:
206+
207+
```tsx
208+
<ErrorBoundary fallback={<span>Something went wrong</span>}>
209+
<React.Suspense fallback={<span>loading...</span>}>
210+
<CatImage />
211+
</React.Suspense>
212+
</ErrorBoundary>
213+
```
214+
215+
### No automatic retry
216+
217+
Once an observable errors, there is no automatic retry. The errored observable stays in ReactFire's global cache under its `observableId`, so unmounting and remounting the same component rejoins the same errored state. The only workaround today is to use a different `observableId`. A retry mechanism is tracked in [#742](https://github.com/FirebaseExtended/reactfire/issues/742).
218+
170219
## Auth
171220

172221
The following samples assume that `FirebaseAppProvider` and `AuthProvider` components exist higher up the component tree (see [setup instructions](#setup) for more detail).
@@ -426,12 +475,16 @@ function CatImage() {
426475
const storage = useStorage();
427476
const catRef = ref(storage, 'cats/newspaper');
428477

429-
const { status, data: imageURL } = useStorageDownloadURL(catRef);
478+
const { status, data: imageURL, error } = useStorageDownloadURL(catRef);
430479

431480
if (status === 'loading') {
432481
return <span>loading...</span>;
433482
}
434483

484+
if (status === 'error') {
485+
return <span>Error: {error.message}</span>;
486+
}
487+
435488
return <img src={imageURL} alt="cat reading the newspaper" />;
436489
}
437490
```

‎src/useObservable.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,12 @@ export function useObservable<T = unknown>(observableId: string, source: Observa
126126
} as ObservableStatus<T>;
127127
}
128128

129-
// throw an error if there is an error
130-
// TODO(jhuleatt) this is the current, tested-for, behavior. But do we actually want it?
131-
if (update.error) {
129+
// In suspense mode, throw errors so a React Error Boundary can catch them.
130+
// In non-suspense mode (the default), surface the error via `status: 'error'` so
131+
// the consumer can handle it locally. `update` already carries the error, so once
132+
// an observable errors `data` retains its last emitted value. There is no automatic
133+
// retry path once an error occurs (see #742).
134+
if (suspenseEnabled && update.error) {
132135
throw update.error;
133136
}
134137

‎test/storage.test.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ describe('Storage', () => {
5858
});
5959

6060
describe('useStorageDownloadURL', () => {
61+
it('surfaces storage/object-not-found as status: error for a nonexistent file', async () => {
62+
const missingRef = ref(storage, `nonexistent/${randomString()}.txt`);
63+
const { result } = renderHook(() => useStorageDownloadURL(missingRef), { wrapper: Provider });
64+
await waitFor(() => expect(result.current.status).toEqual('error'));
65+
expect((result.current.error as any)?.code).toEqual('storage/object-not-found');
66+
});
67+
6168
it('returns the same value as getDownloadURL', async () => {
6269
const someBytes = Uint8Array.from(Buffer.from(new ArrayBuffer(500_000)));
6370
const testFileRef = ref(storage, `${randomString()}/${randomString()}.txt`);

‎test/useObservable.test.tsx‎

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import '@testing-library/jest-dom/extend-expect';
22
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react';
33
import * as React from 'react';
44
import { of, Subject, BehaviorSubject, throwError } from 'rxjs';
5-
import { useObservable } from '../src/index';
5+
import { useObservable, FirebaseAppProvider } from '../src/index';
6+
import { initializeApp } from 'firebase/app';
7+
import { baseConfig } from './appConfig';
68

79
describe('useObservable', () => {
810
afterEach(cleanup);
@@ -124,10 +126,46 @@ describe('useObservable', () => {
124126

125127
act(() => observable$.next('val'));
126128
expect(result.current.isComplete).toEqual(false);
127-
129+
128130
act(() => observable$.complete());
129131
await waitFor(() => expect(result.current.isComplete).toEqual(true));
130132
});
133+
134+
it('surfaces errors via status in non-suspense mode', async () => {
135+
const error = new Error('I am an error');
136+
const observable$ = throwError(error);
137+
138+
const { result } = renderHook(() => useObservable('test-error-non-suspense', observable$, { suspense: false }));
139+
140+
await waitFor(() => expect(result.current.status).toEqual('error'));
141+
expect(result.current.error).toEqual(error);
142+
});
143+
144+
it('surfaces errors via status when no suspense option is provided', async () => {
145+
const error = new Error('default mode error');
146+
const observable$ = throwError(error);
147+
148+
const { result } = renderHook(() => useObservable('test-error-default-mode', observable$));
149+
150+
await waitFor(() => expect(result.current.status).toEqual('error'));
151+
expect(result.current.error).toEqual(error);
152+
});
153+
154+
it('retains last emitted data when observable errors after emitting', async () => {
155+
const subject$ = new Subject<string>();
156+
const error = new Error('late error');
157+
158+
const { result } = renderHook(() => useObservable('test-late-error', subject$, { suspense: false }));
159+
160+
act(() => subject$.next('good value'));
161+
await waitFor(() => expect(result.current.status).toEqual('success'));
162+
expect(result.current.data).toEqual('good value');
163+
164+
act(() => subject$.error(error));
165+
await waitFor(() => expect(result.current.status).toEqual('error'));
166+
expect(result.current.error).toEqual(error);
167+
expect(result.current.data).toEqual('good value');
168+
});
131169
});
132170

133171
describe('Suspense Mode', () => {
@@ -328,5 +366,29 @@ describe('useObservable', () => {
328366
// if useObservable doesn't re-emit, the value here will still be "Jeff"
329367
expect(refreshedComp).toHaveTextContent('James');
330368
});
369+
it('throws an error via FirebaseAppProvider suspense context path', () => {
370+
const spy = vi.spyOn(console, 'error');
371+
spy.mockImplementation(() => {});
372+
373+
const onError = (e: ErrorEvent) => e.preventDefault();
374+
window.addEventListener('error', onError);
375+
376+
const app = initializeApp(baseConfig, 'suspense-context-test');
377+
const error = new Error('context-path error');
378+
const observable$ = throwError(error);
379+
380+
const wrapper = ({ children }: { children: React.ReactNode }) => (
381+
<FirebaseAppProvider firebaseApp={app} suspense={true}>
382+
{children}
383+
</FirebaseAppProvider>
384+
);
385+
386+
expect(() => renderHook(() => useObservable('test-context-suspense-error', observable$), { wrapper })).toThrow(
387+
expect.objectContaining({ message: 'context-path error' })
388+
);
389+
390+
spy.mockRestore();
391+
window.removeEventListener('error', onError);
392+
});
331393
});
332394
});

0 commit comments

Comments
 (0)