Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b113bc3
fix: surface observable errors via status instead of re-throwing
tyler-reitz Jul 10, 2026
776c993
chore: bump version to 4.3.0
tyler-reitz Jul 10, 2026
74aee91
fix: only re-throw observable errors in suspense mode
tyler-reitz Jul 10, 2026
26cd552
chore: gitignore CLAUDE.local files
tyler-reitz Jul 13, 2026
c7d3d6a
fix: address Armando review feedback on observable error state PR
tyler-reitz Jul 15, 2026
13bfb96
chore: regenerate reference docs
tyler-reitz Jul 15, 2026
91ea1ac
chore: regenerate reference docs
tyler-reitz Jul 15, 2026
c6ddca8
fix: correct upgrade guide retry note — remount rejoins cached error …
tyler-reitz Jul 16, 2026
e021670
test: remove storage error test pending Firebase SDK rejection invest…
tyler-reitz Jul 16, 2026
0971021
fix: wrap getDownloadURL in defer to prevent redundant eager requests…
tyler-reitz Jul 16, 2026
7286c57
refactor: remove redundant isComplete override from ObservableStatusE…
tyler-reitz Jul 16, 2026
11286bf
chore: regenerate reference docs
tyler-reitz Jul 16, 2026
0fde812
test: add FirebaseAppProvider suspense context path error test
tyler-reitz Jul 16, 2026
bacf14b
docs: cite #742 for retry follow-up in upgrade guide
tyler-reitz Jul 20, 2026
a26a9c0
test: add window error guard to suspense context path error test
tyler-reitz Jul 20, 2026
7b2adce
Merge remote-tracking branch 'upstream/v5' into fix/observable-error-…
tyler-reitz Jul 23, 2026
f5a0561
Merge branch 'v5' into fix/observable-error-state
tyler-reitz Jul 27, 2026
b17d52c
docs: add v4 to v5 upgrade guide section for the error handling change
tyler-reitz Jul 27, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
CLAUDE.local*
.DS_Store
npm-debug.log

Expand Down
24 changes: 24 additions & 0 deletions docs/upgrade-guide.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
# Upgrade from ReactFire v4 to v5

ReactFire v5 contains breaking changes. This section lists them as they land; add an entry here in any PR that changes public behavior.

## Error handling behavior change

Previously, errors from any reactfire hook were thrown unconditionally, making `status: 'error'` unreachable in practice. In v5, error handling depends on the mode:

- **Non-suspense mode** (default, or `suspense: false`): errors are returned via `status: 'error'` so components can handle them locally.
- **Suspense mode** (`suspense: true`): errors are re-thrown so a React Error Boundary can catch them. No change from prior behavior.

**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:

```tsx
const { status, error } = useStorageDownloadURL(ref);
if (status === 'error') throw error; // re-throw to reach your Error Boundary
```

**If you already check `status` before using `data`**, no change is needed.

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).

---

# Upgrade from ReactFire v3 to v4

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.
Expand Down
55 changes: 54 additions & 1 deletion docs/use.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
* [Initialize product SDKs and register them with ReactFire](#initialize-product-sdks-and-register-them-with-reactfire)
* [Connect to the Firebase Local Emulator Suite](#connect-to-the-firebase-local-emulator-suite)
* [Set up App Check](#set-up-app-check)
- [Error Handling](#error-handling)
* [Non-suspense mode (default)](#non-suspense-mode-default)
* [Suspense mode](#suspense-mode)
* [No automatic retry](#no-automatic-retry)
- [Auth](#auth)
* [Display the current signed-in user](#display-the-current-signed-in-user)
* [Only render a component if a user is signed in](#only-render-a-component-if-a-user-is-signed-in)
Expand Down Expand Up @@ -167,6 +171,51 @@ function FirebaseComponents({ children }) {

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.

## Error Handling

ReactFire hooks report errors from the underlying Firebase observable, and how an error surfaces depends on whether suspense mode is enabled.

### Non-suspense mode (default)

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:

```tsx
function CatImage() {
const storage = useStorage();
const catRef = ref(storage, 'cats/newspaper');

const { status, data: imageURL, error } = useStorageDownloadURL(catRef);

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

if (status === 'error') {
return <span>Error: {error.message}</span>;
}

return <img src={imageURL} alt="cat reading the newspaper" />;
}
```

If a hook emits a value and then errors, `status` becomes `'error'` while `data` keeps its last emitted value.

### Suspense mode

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'`:

```tsx
<ErrorBoundary fallback={<span>Something went wrong</span>}>
<React.Suspense fallback={<span>loading...</span>}>
<CatImage />
</React.Suspense>
</ErrorBoundary>
```

### No automatic retry

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).

## Auth

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

const { status, data: imageURL } = useStorageDownloadURL(catRef);
const { status, data: imageURL, error } = useStorageDownloadURL(catRef);

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

if (status === 'error') {
return <span>Error: {error.message}</span>;
}

return <img src={imageURL} alt="cat reading the newspaper" />;
}
```
Expand Down
9 changes: 6 additions & 3 deletions src/useObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,12 @@ export function useObservable<T = unknown>(observableId: string, source: Observa
} as ObservableStatus<T>;
}

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

Expand Down
7 changes: 7 additions & 0 deletions test/storage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ describe('Storage', () => {
});

describe('useStorageDownloadURL', () => {
it('surfaces storage/object-not-found as status: error for a nonexistent file', async () => {
const missingRef = ref(storage, `nonexistent/${randomString()}.txt`);
const { result } = renderHook(() => useStorageDownloadURL(missingRef), { wrapper: Provider });
await waitFor(() => expect(result.current.status).toEqual('error'));
expect((result.current.error as any)?.code).toEqual('storage/object-not-found');
});

it('returns the same value as getDownloadURL', async () => {
const someBytes = Uint8Array.from(Buffer.from(new ArrayBuffer(500_000)));
const testFileRef = ref(storage, `${randomString()}/${randomString()}.txt`);
Expand Down
66 changes: 64 additions & 2 deletions test/useObservable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import '@testing-library/jest-dom/extend-expect';
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react';
import * as React from 'react';
import { of, Subject, BehaviorSubject, throwError } from 'rxjs';
import { useObservable } from '../src/index';
import { useObservable, FirebaseAppProvider } from '../src/index';
import { initializeApp } from 'firebase/app';
import { baseConfig } from './appConfig';

describe('useObservable', () => {
afterEach(cleanup);
Expand Down Expand Up @@ -124,10 +126,46 @@ describe('useObservable', () => {

act(() => observable$.next('val'));
expect(result.current.isComplete).toEqual(false);

act(() => observable$.complete());
await waitFor(() => expect(result.current.isComplete).toEqual(true));
});

it('surfaces errors via status in non-suspense mode', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The #535 marquee scenario (useStorageDownloadURL on a nonexistent object, default mode, storage emulator) still has no test pinning it — this is the PR's reason for existing. Nice to have before merge, not blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added. Had to pair it with a defer(() => getDownloadURL(ref)) fix in storage.tsx since getDownloadURL was firing an eager network request on every render, which leaked an unhandled rejection when no subscriber caught the result. The fix also eliminates redundant requests on re-renders as a bonus.

const error = new Error('I am an error');
const observable$ = throwError(error);

const { result } = renderHook(() => useObservable('test-error-non-suspense', observable$, { suspense: false }));

await waitFor(() => expect(result.current.status).toEqual('error'));
expect(result.current.error).toEqual(error);
});

it('surfaces errors via status when no suspense option is provided', async () => {
const error = new Error('default mode error');
const observable$ = throwError(error);

const { result } = renderHook(() => useObservable('test-error-default-mode', observable$));

await waitFor(() => expect(result.current.status).toEqual('error'));
expect(result.current.error).toEqual(error);
});

it('retains last emitted data when observable errors after emitting', async () => {
const subject$ = new Subject<string>();
const error = new Error('late error');

const { result } = renderHook(() => useObservable('test-late-error', subject$, { suspense: false }));

act(() => subject$.next('good value'));
await waitFor(() => expect(result.current.status).toEqual('success'));
expect(result.current.data).toEqual('good value');

act(() => subject$.error(error));
await waitFor(() => expect(result.current.status).toEqual('error'));
expect(result.current.error).toEqual(error);
expect(result.current.data).toEqual('good value');
});
});

describe('Suspense Mode', () => {
Expand Down Expand Up @@ -328,5 +366,29 @@ describe('useObservable', () => {
// if useObservable doesn't re-emit, the value here will still be "Jeff"
expect(refreshedComp).toHaveTextContent('James');
});
it('throws an error via FirebaseAppProvider suspense context path', () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verified this exercises the context branch (hook called with no config, so the provider's suspense={true} decides). Minor: the sibling test around line 172 adds a window.addEventListener('error', e => e.preventDefault()) guard to suppress the jsdom uncaught-error noise this kind of throw prints; copying it here would quiet the run. Not blocking.

const spy = vi.spyOn(console, 'error');
spy.mockImplementation(() => {});

const onError = (e: ErrorEvent) => e.preventDefault();
window.addEventListener('error', onError);

const app = initializeApp(baseConfig, 'suspense-context-test');
const error = new Error('context-path error');
const observable$ = throwError(error);

const wrapper = ({ children }: { children: React.ReactNode }) => (
<FirebaseAppProvider firebaseApp={app} suspense={true}>
{children}
</FirebaseAppProvider>
);

expect(() => renderHook(() => useObservable('test-context-suspense-error', observable$), { wrapper })).toThrow(
expect.objectContaining({ message: 'context-path error' })
);

spy.mockRestore();
window.removeEventListener('error', onError);
});
});
});
Loading