From 5ae9070e029a8b2f0ef2af429476e3e96e6fb611 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 16:10:52 +0000 Subject: [PATCH 1/2] fix(OUT-4109): await SWR mutate refetches and enrich fetcher errors Await unawaited mutate() calls in ActivityWrapper, Subtasks, and Subtemplates so post-mutation fetcher() failures are caught by existing try/catch blocks instead of surfacing as unhandled promise rejections. Enrich fetcher errors with HTTP status, URL, and response body snippet for actionable Sentry events. Co-authored-by: Neil Raina --- .../configure-tasks-app/ui/Subtemplates.tsx | 2 +- src/app/detail/ui/ActivityWrapper.tsx | 2 +- src/app/detail/ui/Subtasks.tsx | 2 +- src/utils/fetcher.test.ts | 35 +++++++++++++++++++ src/utils/fetcher.ts | 5 +-- 5 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 src/utils/fetcher.test.ts diff --git a/src/app/configure-tasks-app/ui/Subtemplates.tsx b/src/app/configure-tasks-app/ui/Subtemplates.tsx index f1742287a..22b50d1e9 100644 --- a/src/app/configure-tasks-app/ui/Subtemplates.tsx +++ b/src/app/configure-tasks-app/ui/Subtemplates.tsx @@ -90,7 +90,7 @@ export const Subtemplates = ({ template_id, token }: { template_id: string; toke const optimisticData = sortTaskByDescendingOrder([...currentSubtemplates, tempSubtemplate]) try { - mutate( + await mutate( cacheKey, async () => { const subTask = await createSubTemplate(token, template_id, payload) diff --git a/src/app/detail/ui/ActivityWrapper.tsx b/src/app/detail/ui/ActivityWrapper.tsx index 8bd1b1ff9..4aa5fd240 100644 --- a/src/app/detail/ui/ActivityWrapper.tsx +++ b/src/app/detail/ui/ActivityWrapper.tsx @@ -102,7 +102,7 @@ export const ActivityWrapper = ({ const optimisticData = getOptimisticData(postCommentPayload, activities.data, tempLog) try { - mutate( + await mutate( cacheKey, async () => { shouldRefetchRef.current = false diff --git a/src/app/detail/ui/Subtasks.tsx b/src/app/detail/ui/Subtasks.tsx index 51c45cf86..87f077855 100644 --- a/src/app/detail/ui/Subtasks.tsx +++ b/src/app/detail/ui/Subtasks.tsx @@ -104,7 +104,7 @@ export const Subtasks = ({ ) const optimisticData = subTasks?.tasks ? sortSubtasksByPriority([...subTasks.tasks, tempSubtask]) : [tempSubtask] try { - mutate( + await mutate( cacheKey, async () => { const subTask = await handleCreate(token, payload, { disableSubtaskTemplates: true }) diff --git a/src/utils/fetcher.test.ts b/src/utils/fetcher.test.ts new file mode 100644 index 000000000..9f2427efc --- /dev/null +++ b/src/utils/fetcher.test.ts @@ -0,0 +1,35 @@ +import { fetcher } from '@/utils/fetcher' + +describe('fetcher', () => { + const originalFetch = global.fetch + + afterEach(() => { + global.fetch = originalFetch + jest.restoreAllMocks() + }) + + it('returns parsed JSON for successful responses', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ tasks: [] }), + }) + + await expect(fetcher('/api/tasks/?token=test')).resolves.toEqual({ tasks: [] }) + }) + + it('throws an error with status, url, and response body for failed responses', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + text: async () => 'Internal server error', + }) + + await expect(fetcher('/api/tasks/?token=test')).rejects.toThrow( + 'An error occurred while fetching the data. [500] /api/tasks/?token=test: Internal server error', + ) + }) + + it('returns undefined when url is null', async () => { + await expect(fetcher(null)).resolves.toBeUndefined() + }) +}) diff --git a/src/utils/fetcher.ts b/src/utils/fetcher.ts index 99f9dfe43..46e1b2c34 100644 --- a/src/utils/fetcher.ts +++ b/src/utils/fetcher.ts @@ -4,8 +4,9 @@ export const fetcher = async (url: string | null) => { const res = await fetch(url) if (!res.ok) { - const error = new Error('An error occurred while fetching the data.') - throw error + const responseText = await res.text().catch(() => '') + const detail = responseText ? `: ${responseText.slice(0, 200)}` : '' + throw new Error(`An error occurred while fetching the data. [${res.status}] ${url}${detail}`) } return res.json() From bb45f2c15b4317b56da0fcb7aea693ba67ff98e5 Mon Sep 17 00:00:00 2001 From: priosshrsth Date: Wed, 26 Aug 2026 03:18:34 +0000 Subject: [PATCH 2/2] OUT-4109 | Catch debounced SWR revalidation rejections and drop token from fetcher errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unawaited mutate() fix missed the fire-and-forget debounceMutate(cacheKey) paths, which reject the same way when revalidation fails, and it made two non-async handlers await, so the branch did not typecheck. The enriched error also embedded the full URL, which carries the Copilot session token — that would have shipped a live credential to Sentry on every failed fetch. Log the path only and attach the status, which swr-config's onErrorRetry already reads to skip retrying 404s. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y4tF84tzJW7Bo1DehsDX22 --- src/app/configure-tasks-app/ui/Subtemplates.tsx | 5 +++-- src/app/detail/ui/ActivityWrapper.tsx | 3 ++- src/app/detail/ui/Subtasks.tsx | 5 +++-- src/utils/fetcher.test.ts | 16 +++++++++++++--- src/utils/fetcher.ts | 10 +++++++--- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/app/configure-tasks-app/ui/Subtemplates.tsx b/src/app/configure-tasks-app/ui/Subtemplates.tsx index 22b50d1e9..dfae06283 100644 --- a/src/app/configure-tasks-app/ui/Subtemplates.tsx +++ b/src/app/configure-tasks-app/ui/Subtemplates.tsx @@ -53,7 +53,8 @@ export const Subtemplates = ({ template_id, token }: { template_id: string; toke const { mutate } = useSWRConfig() - const _debounceMutate = async (cacheKey: string) => await mutate(cacheKey) + const _debounceMutate = (cacheKey: string) => + mutate(cacheKey).catch((error) => console.error('Failed to revalidate subtemplates:', error)) const debounceMutate = useDebounce(_debounceMutate, 200) useEffect(() => { @@ -68,7 +69,7 @@ export const Subtemplates = ({ template_id, token }: { template_id: string; toke debounceMutate(cacheKey) }, [activeTemplate?.subTaskTemplates]) - const handleSubtemplateCreation = (payload: CreateTemplateRequest) => { + const handleSubtemplateCreation = async (payload: CreateTemplateRequest) => { const tempId = generateRandomString('temp-template') setOptimisticUpdates((prev) => [ ...prev, diff --git a/src/app/detail/ui/ActivityWrapper.tsx b/src/app/detail/ui/ActivityWrapper.tsx index 4aa5fd240..672c2c2a9 100644 --- a/src/app/detail/ui/ActivityWrapper.tsx +++ b/src/app/detail/ui/ActivityWrapper.tsx @@ -55,7 +55,8 @@ export const ActivityWrapper = ({ useScrollToElement('commentId') - const _debounceMutate = async (cacheKey: string) => await mutate(cacheKey) + const _debounceMutate = (cacheKey: string) => + mutate(cacheKey).catch((error) => console.error('Failed to revalidate activity logs:', error)) const debounceMutate = useDebounce(_debounceMutate, 300) const shouldRefetchRef = useRef(true) //preventing double fetching from comment apis. Due to optimistic update revalidation, we are already fetching logs there. So no need to refetch in case for comment creation and deletion. diff --git a/src/app/detail/ui/Subtasks.tsx b/src/app/detail/ui/Subtasks.tsx index 87f077855..579a5311e 100644 --- a/src/app/detail/ui/Subtasks.tsx +++ b/src/app/detail/ui/Subtasks.tsx @@ -67,7 +67,8 @@ export const Subtasks = ({ const { mutate } = useSWRConfig() - const _debounceMutate = async (cacheKey: string) => await mutate(cacheKey) + const _debounceMutate = (cacheKey: string) => + mutate(cacheKey).catch((error) => console.error('Failed to revalidate subtasks:', error)) const debounceMutate = useDebounce(_debounceMutate, 200) useEffect(() => { @@ -84,7 +85,7 @@ export const Subtasks = ({ setLastUpdated(activeTask?.lastSubtaskUpdated) }, [activeTask?.lastSubtaskUpdated]) - const handleSubTaskCreation = (payload: CreateTaskRequest) => { + const handleSubTaskCreation = async (payload: CreateTaskRequest) => { const tempId = generateRandomString('temp-task') setOptimisticUpdates((prev) => [ ...prev, diff --git a/src/utils/fetcher.test.ts b/src/utils/fetcher.test.ts index 9f2427efc..d86f9b44d 100644 --- a/src/utils/fetcher.test.ts +++ b/src/utils/fetcher.test.ts @@ -17,18 +17,28 @@ describe('fetcher', () => { await expect(fetcher('/api/tasks/?token=test')).resolves.toEqual({ tasks: [] }) }) - it('throws an error with status, url, and response body for failed responses', async () => { + it('throws an error with status, path, and response body, without the token', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500, text: async () => 'Internal server error', }) - await expect(fetcher('/api/tasks/?token=test')).rejects.toThrow( - 'An error occurred while fetching the data. [500] /api/tasks/?token=test: Internal server error', + await expect(fetcher('/api/tasks/?token=secret-token')).rejects.toThrow( + 'An error occurred while fetching the data. [500] /api/tasks/: Internal server error', ) }) + it('attaches the response status to the error so onErrorRetry can skip 404s', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + text: async () => '', + }) + + await expect(fetcher('/api/tasks/?token=test')).rejects.toMatchObject({ status: 404 }) + }) + it('returns undefined when url is null', async () => { await expect(fetcher(null)).resolves.toBeUndefined() }) diff --git a/src/utils/fetcher.ts b/src/utils/fetcher.ts index 46e1b2c34..a9494d186 100644 --- a/src/utils/fetcher.ts +++ b/src/utils/fetcher.ts @@ -4,9 +4,13 @@ export const fetcher = async (url: string | null) => { const res = await fetch(url) if (!res.ok) { - const responseText = await res.text().catch(() => '') - const detail = responseText ? `: ${responseText.slice(0, 200)}` : '' - throw new Error(`An error occurred while fetching the data. [${res.status}] ${url}${detail}`) + const body = await res.text().catch(() => '') + // url carries the Copilot session token in its query string — keep it out of error reporting + const path = url.split('?')[0] + const error = new Error( + `An error occurred while fetching the data. [${res.status}] ${path}${body ? `: ${body.slice(0, 200)}` : ''}`, + ) + throw Object.assign(error, { status: res.status }) } return res.json()