Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/async-callback-rejections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Suppress "Uncaught (in promise)" console noise for fire-and-forget `useAsyncCallback` call sites; errors are still surfaced to callers that await the returned promise and captured in `AsyncState`
2 changes: 1 addition & 1 deletion src/app/hooks/useAsyncCallback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('useAsyncCallback', () => {
);

await act(async () => {
await result.current[1]().catch(() => {});
await expect(result.current[1]()).rejects.toBe(boom);
});

expect(result.current[0]).toEqual({ status: AsyncStatus.Error, error: boom });
Expand Down
17 changes: 16 additions & 1 deletion src/app/hooks/useAsyncCallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export const useAsync = <TData, TError, TArgs extends unknown[]>(
});
});
}
// Re-throw so .then()/.catch() callers see the rejection and success
// handlers are skipped. Fire-and-forget unhandled-rejection warnings are
// suppressed at the useAsyncCallback level via a no-op .catch wrapper.
throw e;
}

Expand Down Expand Up @@ -102,7 +105,19 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
status: AsyncStatus.Idle,
});

const callback = useAsync(asyncCallback, setState);
const innerCallback = useAsync(asyncCallback, setState);

// Re-throw preserves rejection for callers that await/chain; the no-op .catch
// suppresses "Uncaught (in promise)" for fire-and-forget call sites (e.g.
// loadSrc() in a useEffect) without swallowing the error from intentional callers.
const callback = useCallback(
(...args: TArgs): Promise<TData> => {
const p = innerCallback(...args);
p.catch(() => {});
return p;
},
[innerCallback]
) as AsyncCallback<TArgs, TData>;

Comment on lines +109 to 121
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

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

The added no-op p.catch(() => {}) marks the rejection as handled, which prevents unhandledrejection from firing. In this repo Sentry is initialized with default browser integrations, so this will likely stop fire-and-forget failures from being captured/reported and can make production errors harder to detect. If the goal is to surface failures to the app’s error handling, consider either (a) removing the no-op catch and fixing call sites that truly need to ignore errors, or (b) reporting the error explicitly (e.g., via Sentry/logging) and/or rethrowing on a separate task so it still reaches global error handling.

Suggested change
// Re-throw preserves rejection for callers that await/chain; the no-op .catch
// suppresses "Uncaught (in promise)" for fire-and-forget call sites (e.g.
// loadSrc() in a useEffect) without swallowing the error from intentional callers.
const callback = useCallback(
(...args: TArgs): Promise<TData> => {
const p = innerCallback(...args);
p.catch(() => {});
return p;
},
[innerCallback]
) as AsyncCallback<TArgs, TData>;
const callback = innerCallback as AsyncCallback<TArgs, TData>;

Copilot uses AI. Check for mistakes.
return [state, callback, setState];
};
Expand Down
Loading