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
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
2 changes: 1 addition & 1 deletion docs/reference/functions/StorageImage.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion docs/reference/functions/useObservable.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/reference/functions/useStorageDownloadURL.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/reference/functions/useStorageTask.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions docs/reference/interfaces/ObservableStatusError.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions docs/reference/interfaces/ObservableStatusLoading.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/reference/type-aliases/ObservableStatus.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions docs/reference/type-aliases/StorageImageProps.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.2 to v4.3

ReactFire v4.3.0 changes how errors are surfaced in non-suspense mode (the default).

## Error handling behavior change

Previously, errors from any reactfire hook were thrown unconditionally, making `status: 'error'` unreachable in practice. In v4.3.0, 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 as a follow-up issue.

---

# 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
6 changes: 5 additions & 1 deletion docs/use.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,12 +426,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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "4.2.3",
"version": "4.3.0",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

See "Semver" in my review — the description says breaking, this says minor. Worth an explicit 4.3.0-vs-5.0.0 decision (this number only drives exp canary versions either way).

"license": "MIT",
"type": "module",
"main": "dist/index.umd.cjs",
Expand Down
3 changes: 2 additions & 1 deletion src/storage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';
import { getDownloadURL, fromTask } from 'rxfire/storage';
import { defer } from 'rxjs';
import { ReactFireOptions, useObservable, ObservableStatus, useStorage } from './';
import { useSuspenseEnabledFromConfigAndContext } from './firebaseApp';
import { ref } from 'firebase/storage';
Expand Down Expand Up @@ -28,7 +29,7 @@ export function useStorageTask<T = unknown>(task: UploadTask, ref: StorageRefere
*/
export function useStorageDownloadURL<T = string>(ref: StorageReference, options?: ReactFireOptions<T>): ObservableStatus<string | T> {
const observableId = `storage:downloadUrl:${ref.toString()}`;
const observable$ = getDownloadURL(ref);
const observable$ = defer(() => getDownloadURL(ref));

return useObservable(observableId, observable$, options);
}
Expand Down
21 changes: 15 additions & 6 deletions src/useObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ export interface ObservableStatusSuccess<T> extends ObservableStatusBase<T> {

export interface ObservableStatusError<T> extends ObservableStatusBase<T> {
status: 'error';
isComplete: true;
error: Error;
}

Expand All @@ -83,6 +82,17 @@ export interface ObservableStatusLoading<T> extends ObservableStatusBase<T> {

export type ObservableStatus<T> = ObservableStatusLoading<T> | ObservableStatusError<T> | ObservableStatusSuccess<T>;

/**
* Subscribe to an Observable and return its current status.
*
* Error handling depends on the suspense mode:
* - Non-suspense mode (default): errors are returned as `{ status: 'error', error }` so the
* component can handle them locally without needing a React Error Boundary.
* - Suspense mode (`suspense: true`): errors are re-thrown so a React Error Boundary can catch them.
*
* If the observable emits a value and then errors, `data` retains the last emitted value and
* `status` changes to `'error'`. There is no automatic retry path once an error occurs.
*/
export function useObservable<T = unknown>(observableId: string, source: Observable<T>, config: ReactFireOptions = {}): ObservableStatus<T> {
if (!observableId) {
throw new Error('cannot call useObservable without an observableId');
Expand All @@ -105,9 +115,8 @@ export function useObservable<T = unknown>(observableId: string, source: Observa
next: () => {
onStoreChange();
},
error: (e) => {
error: () => {
onStoreChange();
throw e;
},
complete: () => {
onStoreChange();
Expand Down Expand Up @@ -145,9 +154,9 @@ 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 React Error Boundaries can catch them.
// In non-suspense mode, surface errors via status so consumers can handle them locally.
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
60 changes: 58 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,23 @@ 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', () => {
const spy = vi.spyOn(console, 'error');
spy.mockImplementation(() => {});

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(error);

spy.mockRestore();
});
});
});
Loading