Skip to content

Commit c1e104a

Browse files
committed
🐛(frontend) abort check media status unmount
When a media file is uploaded, the application checks its status every 5 seconds until it becomes 'ready'. If the user navigates away from the page before the media is ready, the application should stop checking the status to avoid unnecessary API calls. This can be achieved by using an AbortController to signal when the component is unmounted, allowing the loop to exit gracefully.
1 parent 21c73fd commit c1e104a

4 files changed

Lines changed: 230 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ and this project adheres to
66

77
## [Unreleased]
88

9+
### Fixed
10+
11+
🐛(frontend) abort check media status unmount #2194
12+
913
## [v4.8.6] - 2026-04-08
1014

1115
### Added
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import fetchMock from 'fetch-mock';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import {
5+
checkDocMediaStatus,
6+
loopCheckDocMediaStatus,
7+
} from '../checkDocMediaStatus';
8+
9+
const VALID_URL = 'http://test.jest/media-check/some-file-id';
10+
11+
describe('checkDocMediaStatus', () => {
12+
beforeEach(() => {
13+
fetchMock.restore();
14+
});
15+
16+
afterEach(() => {
17+
fetchMock.restore();
18+
});
19+
20+
it('returns the response when the status is ready', async () => {
21+
fetchMock.get(VALID_URL, {
22+
body: { status: 'ready', file: '/media/some-file.pdf' },
23+
});
24+
25+
const result = await checkDocMediaStatus({ urlMedia: VALID_URL });
26+
27+
expect(result).toEqual({ status: 'ready', file: '/media/some-file.pdf' });
28+
expect(fetchMock.lastOptions(VALID_URL)).toMatchObject({
29+
credentials: 'include',
30+
});
31+
});
32+
33+
it('returns the response when the status is processing', async () => {
34+
fetchMock.get(VALID_URL, {
35+
body: { status: 'processing' },
36+
});
37+
38+
const result = await checkDocMediaStatus({ urlMedia: VALID_URL });
39+
40+
expect(result).toEqual({ status: 'processing' });
41+
});
42+
43+
it('throws an APIError when the URL is not safe', async () => {
44+
await expect(
45+
checkDocMediaStatus({ urlMedia: 'javascript:alert(1)' }),
46+
).rejects.toMatchObject({ status: 400 });
47+
48+
expect(fetchMock.calls().length).toBe(0);
49+
});
50+
51+
it('throws an APIError when the URL does not contain the analyze path', async () => {
52+
await expect(
53+
checkDocMediaStatus({ urlMedia: 'http://test.jest/other/path' }),
54+
).rejects.toMatchObject({ status: 400 });
55+
56+
expect(fetchMock.calls().length).toBe(0);
57+
});
58+
59+
it('throws an APIError when the fetch response is not ok', async () => {
60+
fetchMock.get(VALID_URL, {
61+
status: 500,
62+
body: JSON.stringify({ detail: 'Internal server error' }),
63+
});
64+
65+
await expect(
66+
checkDocMediaStatus({ urlMedia: VALID_URL }),
67+
).rejects.toMatchObject({ status: 500 });
68+
});
69+
70+
it('forwards the AbortSignal to fetch', async () => {
71+
const controller = new AbortController();
72+
controller.abort();
73+
74+
fetchMock.get(VALID_URL, { body: { status: 'ready' } });
75+
76+
await expect(
77+
checkDocMediaStatus({ urlMedia: VALID_URL, signal: controller.signal }),
78+
).rejects.toThrow();
79+
});
80+
});
81+
82+
describe('loopCheckDocMediaStatus', () => {
83+
beforeEach(() => {
84+
vi.useFakeTimers();
85+
fetchMock.restore();
86+
});
87+
88+
afterEach(() => {
89+
vi.useRealTimers();
90+
fetchMock.restore();
91+
});
92+
93+
it('resolves immediately when the status is already ready', async () => {
94+
fetchMock.get(VALID_URL, {
95+
body: { status: 'ready', file: '/media/file.pdf' },
96+
});
97+
98+
const result = await loopCheckDocMediaStatus(
99+
VALID_URL,
100+
new AbortController().signal,
101+
);
102+
103+
expect(result).toEqual({ status: 'ready', file: '/media/file.pdf' });
104+
expect(fetchMock.calls().length).toBe(1);
105+
});
106+
107+
it('retries until the status becomes ready', async () => {
108+
let callCount = 0;
109+
fetchMock.mock(VALID_URL, () => {
110+
callCount++;
111+
return {
112+
status: 200,
113+
body: JSON.stringify(
114+
callCount >= 3
115+
? { status: 'ready', file: '/media/file.pdf' }
116+
: { status: 'processing' },
117+
),
118+
};
119+
});
120+
121+
const promise = loopCheckDocMediaStatus(
122+
VALID_URL,
123+
new AbortController().signal,
124+
);
125+
126+
// Advance timers for each sleep between retries
127+
await vi.runAllTimersAsync();
128+
129+
const result = await promise;
130+
131+
expect(result).toEqual({ status: 'ready', file: '/media/file.pdf' });
132+
expect(fetchMock.calls().length).toBe(3);
133+
});
134+
135+
it('throws an AbortError immediately when the signal is already aborted', async () => {
136+
const controller = new AbortController();
137+
controller.abort();
138+
139+
fetchMock.get(VALID_URL, { body: { status: 'processing' } });
140+
141+
await expect(
142+
loopCheckDocMediaStatus(VALID_URL, controller.signal),
143+
).rejects.toMatchObject({ name: 'AbortError' });
144+
145+
expect(fetchMock.calls().length).toBe(0);
146+
});
147+
148+
it('stops the loop when the signal is aborted during a sleep', async () => {
149+
fetchMock.get(VALID_URL, { body: { status: 'processing' } });
150+
151+
const controller = new AbortController();
152+
153+
const rejectExpectation = expect(
154+
loopCheckDocMediaStatus(VALID_URL, controller.signal),
155+
).rejects.toMatchObject({ name: 'AbortError' });
156+
157+
controller.abort();
158+
159+
await rejectExpectation;
160+
// Only the first request should have been made
161+
expect(fetchMock.calls().length).toBe(1);
162+
});
163+
164+
it('rejects when a fetch error occurs', async () => {
165+
fetchMock.get(VALID_URL, {
166+
status: 500,
167+
body: JSON.stringify({ detail: 'Internal server error' }),
168+
});
169+
170+
// Error happens on the first fetch — no timer advancement needed.
171+
await expect(
172+
loopCheckDocMediaStatus(VALID_URL, new AbortController().signal),
173+
).rejects.toMatchObject({ status: 500 });
174+
175+
expect(fetchMock.calls().length).toBe(1);
176+
});
177+
});

src/frontend/apps/impress/src/features/docs/doc-editor/api/checkDocMediaStatus.tsx

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { APIError, errorCauses } from '@/api';
2-
import { sleep } from '@/utils';
32
import { isSafeUrl } from '@/utils/url';
43

54
import { ANALYZE_URL } from '../conf';
@@ -11,17 +10,20 @@ interface CheckDocMediaStatusResponse {
1110

1211
interface CheckDocMediaStatus {
1312
urlMedia: string;
13+
signal?: AbortSignal;
1414
}
1515

1616
export const checkDocMediaStatus = async ({
1717
urlMedia,
18+
signal,
1819
}: CheckDocMediaStatus): Promise<CheckDocMediaStatusResponse> => {
1920
if (!isSafeUrl(urlMedia) || !urlMedia.includes(ANALYZE_URL)) {
2021
throw new APIError('Url invalid', { status: 400 });
2122
}
2223

2324
const response = await fetch(urlMedia, {
2425
credentials: 'include',
26+
signal,
2527
});
2628

2729
if (!response.ok) {
@@ -34,27 +36,56 @@ export const checkDocMediaStatus = async ({
3436
return response.json() as Promise<CheckDocMediaStatusResponse>;
3537
};
3638

39+
/**
40+
* A sleep function that can be aborted using an AbortSignal.
41+
* If the signal is aborted, the promise will reject with an 'Aborted' error.
42+
* @param ms The number of milliseconds to sleep.
43+
* @param signal The AbortSignal to cancel the sleep.
44+
* @returns A promise that resolves after the specified time or rejects if aborted.
45+
*/
46+
const abortableSleep = (ms: number, signal: AbortSignal) =>
47+
new Promise<void>((resolve, reject) => {
48+
const timeout = setTimeout(resolve, ms);
49+
signal.addEventListener(
50+
'abort',
51+
() => {
52+
clearTimeout(timeout);
53+
reject(new DOMException('Aborted', 'AbortError'));
54+
},
55+
{ once: true },
56+
);
57+
});
58+
3759
/**
3860
* Upload file can be analyzed on the server side,
3961
* we had this function to wait for the analysis to be done
4062
* before returning the file url. It will keep the loader
4163
* on the upload button until the analysis is done.
4264
* @param url
65+
* @param signal AbortSignal to cancel the loop (e.g. on component unmount)
4366
* @returns Promise<CheckDocMediaStatusResponse> status_code
4467
* @description Waits for the upload to be analyzed by checking the status of the file.
4568
*/
4669
export const loopCheckDocMediaStatus = async (
4770
url: string,
71+
signal: AbortSignal,
4872
): Promise<CheckDocMediaStatusResponse> => {
4973
const SLEEP_TIME = 5000;
50-
const response = await checkDocMediaStatus({
51-
urlMedia: url,
52-
});
74+
75+
/**
76+
* Check if the signal has been aborted before making the API call.
77+
* This prevents unnecessary API calls and allows for a faster response to cancellation.
78+
*/
79+
if (signal.aborted) {
80+
throw new DOMException('Aborted', 'AbortError');
81+
}
82+
83+
const response = await checkDocMediaStatus({ urlMedia: url, signal });
5384

5485
if (response.status === 'ready') {
5586
return response;
56-
} else {
57-
await sleep(SLEEP_TIME);
58-
return await loopCheckDocMediaStatus(url);
5987
}
88+
89+
await abortableSleep(SLEEP_TIME, signal);
90+
return loopCheckDocMediaStatus(url, signal);
6091
};

src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/UploadLoaderBlock.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,9 @@ const UploadLoaderBlockComponent = ({
7272
}
7373

7474
const url = block.props.blockUploadUrl;
75+
const controller = new AbortController();
7576

76-
loopCheckDocMediaStatus(url)
77+
loopCheckDocMediaStatus(url, controller.signal)
7778
.then((response) => {
7879
// Add random delay to reduce collision probability during collaboration
7980
const randomDelay = Math.random() * 800;
@@ -101,7 +102,11 @@ const UploadLoaderBlockComponent = ({
101102
}
102103
}, randomDelay);
103104
})
104-
.catch((error) => {
105+
.catch((error: unknown) => {
106+
if (error instanceof DOMException && error.name === 'AbortError') {
107+
return;
108+
}
109+
105110
console.error('Error analyzing file:', error);
106111

107112
try {
@@ -118,6 +123,10 @@ const UploadLoaderBlockComponent = ({
118123
/* During collaboration, another user might have updated the block */
119124
}
120125
});
126+
127+
return () => {
128+
controller.abort();
129+
};
121130
}, [block, editor, mediaUrl, isEditable]);
122131

123132
return (

0 commit comments

Comments
 (0)