Skip to content

Commit 80acfc4

Browse files
author
Olavo Parno
committed
Merge branch 'develop'
2 parents f98740f + 8d79190 commit 80acfc4

File tree

10 files changed

+367
-415
lines changed

10 files changed

+367
-415
lines changed

.github/workflows/npm-publish.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,21 @@ jobs:
99
build:
1010
runs-on: ubuntu-latest
1111
steps:
12-
- uses: actions/checkout@v2
13-
- uses: actions/setup-node@v2
12+
- uses: actions/checkout@v4
13+
- uses: actions/setup-node@v4
1414
with:
15-
node-version: 16
15+
node-version: 20
1616
- run: npm install
1717
- run: npm run build
1818

1919
publish-npm:
2020
needs: build
2121
runs-on: ubuntu-latest
2222
steps:
23-
- uses: actions/checkout@v2
24-
- uses: actions/setup-node@v2
23+
- uses: actions/checkout@v4
24+
- uses: actions/setup-node@v4
2525
with:
26-
node-version: 16
26+
node-version: 20
2727
registry-url: https://registry.npmjs.org/
2828
- run: npm install
2929
- run: npm publish

.github/workflows/pull-request.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ jobs:
1313

1414
strategy:
1515
matrix:
16-
node-version: [12.x, 14.x, 16.x]
16+
node-version: [18.x, 20.x]
1717
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
1818

1919
steps:
20-
- uses: actions/checkout@v2
20+
- uses: actions/checkout@v4
2121
- name: Use Node.js ${{ matrix.node-version }}
22-
uses: actions/setup-node@v2
22+
uses: actions/setup-node@v4
2323
with:
2424
node-version: ${{ matrix.node-version }}
2525
cache: "npm"

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ build
99
dist
1010
.rpt2_cache
1111

12+
# Editor directories and files
13+
.idea
14+
1215
# misc
1316
.DS_Store
1417
.env
@@ -24,4 +27,4 @@ yarn-error.log*
2427
.vercel
2528

2629
coverage
27-
.dccache
30+
.dccache

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
44

5+
## [1.3.0](https://github.com/the-bugging/react-use-downloader/compare/v1.2.10...v1.3.0) (2025-06-15)
6+
7+
8+
### Features
9+
10+
* expose error response ([82e8cd2](https://github.com/the-bugging/react-use-downloader/commit/82e8cd2538e52e1a0701cdb3bcddf2a2f351473f))
11+
512
### [1.2.10](https://github.com/the-bugging/react-use-downloader/compare/v1.2.9...v1.2.10) (2025-03-10)
613

714

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
| Statements | Branches | Functions | Lines |
1010
| ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
11-
| ![Statements](https://img.shields.io/badge/statements-86.44%25-yellow.svg?style=flat&logo=jest) | ![Branches](https://img.shields.io/badge/branches-68.62%25-red.svg?style=flat&logo=jest) | ![Functions](https://img.shields.io/badge/functions-77.14%25-red.svg?style=flat&logo=jest) | ![Lines](https://img.shields.io/badge/lines-86.91%25-yellow.svg?style=flat&logo=jest) |
11+
| ![Statements](https://img.shields.io/badge/statements-93.7%25-brightgreen.svg?style=flat&logo=jest) | ![Branches](https://img.shields.io/badge/branches-73.58%25-red.svg?style=flat&logo=jest) | ![Functions](https://img.shields.io/badge/functions-89.18%25-yellow.svg?style=flat&logo=jest) | ![Lines](https://img.shields.io/badge/lines-94.78%25-brightgreen.svg?style=flat&logo=jest) |
1212

1313
## Table of Contents
1414

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-use-downloader",
3-
"version": "1.2.10",
3+
"version": "1.3.0",
44
"description": "Creates a download handler function and gives progress information",
55
"author": "Olavo Parno",
66
"license": "MIT",

src/__tests__/index.spec.ts

Lines changed: 91 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import { renderHook, act } from '@testing-library/react-hooks';
88
import useDownloader, { jsDownload } from '../index';
99
import { WindowDownloaderEmbedded } from '../types';
1010

11+
// Helper noop function to avoid linter error (intentionally empty for Promise executor and catch)
12+
function noop() {
13+
/* intentionally empty for Promise executor */
14+
}
15+
1116
const expectedKeys = [
1217
'elapsed',
1318
'percentage',
@@ -207,22 +212,20 @@ describe('useDownloader failures', () => {
207212
it('should start download with response.ok false and an error from the response', async () => {
208213
const { result, waitForNextUpdate } = renderHook(() => useDownloader());
209214

210-
global.window.fetch = jest.fn(() =>
211-
Promise.resolve(
212-
new Response(
213-
JSON.stringify({
214-
error: 'File download not allowed',
215-
reason:
216-
'User must complete verification before accessing this file.',
217-
}),
218-
{
219-
status: 403,
220-
statusText: 'Forbidden',
221-
headers: { 'Content-Type': 'application/json' },
222-
}
223-
)
224-
)
215+
const errorResponse = new Response(
216+
JSON.stringify({
217+
error: 'File download not allowed',
218+
reason: 'User must complete verification before accessing this file.',
219+
}),
220+
{
221+
status: 403,
222+
statusText: 'Forbidden',
223+
headers: { 'Content-Type': 'application/json' },
224+
}
225225
);
226+
const resultErrorResponse = errorResponse.clone();
227+
228+
global.window.fetch = jest.fn(() => Promise.resolve(errorResponse));
226229

227230
expect(result.current.error).toBeNull();
228231

@@ -237,6 +240,7 @@ describe('useDownloader failures', () => {
237240
expect(result.current.error).toEqual({
238241
errorMessage:
239242
'403 - Forbidden: File download not allowed: User must complete verification before accessing this file.',
243+
errorResponse: resultErrorResponse,
240244
});
241245
});
242246

@@ -308,3 +312,75 @@ describe('useDownloader failures', () => {
308312
});
309313
});
310314
});
315+
316+
describe('useDownloader cancel and error mapping', () => {
317+
it('should cancel an in-progress download', async () => {
318+
const { result, waitForNextUpdate } = renderHook(() => useDownloader());
319+
320+
// Mock fetch to return a stream that never ends
321+
global.window.fetch = jest.fn(() =>
322+
Promise.resolve({
323+
ok: true,
324+
headers: {
325+
get: () => null,
326+
},
327+
body: {
328+
getReader: () => ({
329+
read: () => new Promise(noop), // never resolves, avoids linter error
330+
cancel: jest.fn(),
331+
}),
332+
},
333+
blob: () => Promise.resolve(new Blob()),
334+
})
335+
) as any;
336+
337+
act(() => {
338+
result.current.download('https://url.com', 'filename');
339+
});
340+
341+
expect(result.current.isInProgress).toBeTruthy();
342+
343+
// Call cancel
344+
act(() => {
345+
result.current.cancel();
346+
});
347+
348+
// Wait for state update (should not throw if no update)
349+
await waitForNextUpdate({ timeout: 100 }).catch(noop);
350+
expect(result.current.isInProgress).toBeFalsy();
351+
});
352+
353+
it('should map known error messages to user-friendly errors', async () => {
354+
const { result, waitForNextUpdate } = renderHook(() => useDownloader());
355+
356+
// Mock fetch to return a stream that errors
357+
global.window.fetch = jest.fn(() =>
358+
Promise.resolve({
359+
ok: true,
360+
headers: {
361+
get: () => null,
362+
},
363+
body: {
364+
getReader: () => ({
365+
read: () =>
366+
Promise.reject(
367+
new Error(
368+
"Failed to execute 'enqueue' on 'ReadableStreamDefaultController': Cannot enqueue a chunk into an errored readable stream"
369+
)
370+
),
371+
cancel: jest.fn(),
372+
}),
373+
},
374+
blob: () => Promise.resolve(new Blob()),
375+
})
376+
) as any;
377+
378+
act(() => {
379+
result.current.download('https://url.com', 'filename');
380+
});
381+
382+
// Wait for state update
383+
await waitForNextUpdate({ timeout: 100 }).catch(noop);
384+
expect(result.current.error).toEqual({ errorMessage: 'Download canceled' });
385+
});
386+
});

src/index.ts

Lines changed: 43 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ export const resolver =
2222
}: ResolverProps) =>
2323
(response: Response): Response => {
2424
if (!response.ok) {
25-
console.error(`${response.status} ${response.type} ${response.statusText}`);
25+
console.error(
26+
`${response.status} ${response.type} ${response.statusText}`
27+
);
2628

2729
throw response;
2830
}
@@ -102,14 +104,8 @@ export const jsDownload = (
102104

103105
const currentWindow = window as unknown as WindowDownloaderEmbedded;
104106

105-
if (
106-
typeof currentWindow.navigator
107-
.msSaveBlob !== 'undefined'
108-
) {
109-
return currentWindow.navigator.msSaveBlob(
110-
blob,
111-
filename
112-
);
107+
if (typeof currentWindow.navigator.msSaveBlob !== 'undefined') {
108+
return currentWindow.navigator.msSaveBlob(blob, filename);
113109
}
114110

115111
const blobURL =
@@ -152,7 +148,7 @@ export default function useDownloader(
152148
const [elapsed, setElapsed] = useState(0);
153149
const [percentage, setPercentage] = useState(0);
154150
const [size, setSize] = useState(0);
155-
const [error, setError] = useState<ErrorMessage>(null);
151+
const [internalError, setInternalError] = useState<ErrorMessage>(null);
156152
const [isInProgress, setIsInProgress] = useState(false);
157153

158154
const controllerRef = useRef<null | ReadableStreamController<Uint8Array>>(
@@ -171,7 +167,7 @@ export default function useDownloader(
171167
'Download canceled',
172168
'The user aborted a request.': 'Download timed out',
173169
};
174-
setError(() => {
170+
setInternalError(() => {
175171
const resolvedError = errorMap[err.message as keyof typeof errorMap]
176172
? errorMap[err.message as keyof typeof errorMap]
177173
: err.message;
@@ -207,7 +203,7 @@ export default function useDownloader(
207203
if (isInProgress) return null;
208204

209205
clearAllStateCallback();
210-
setError(() => null);
206+
setInternalError(() => null);
211207
setIsInProgress(() => true);
212208

213209
const intervalId = setInterval(
@@ -244,35 +240,39 @@ export default function useDownloader(
244240
})
245241
.catch(async (error) => {
246242
clearAllStateCallback();
243+
let errorResponse = null;
247244

248-
249245
const errorMessage = await (async () => {
250246
if (error instanceof Response) {
251-
const contentType = error.headers.get("Content-Type") || "";
252-
const isJson = contentType.includes("application/json");
253-
247+
errorResponse = error.clone();
248+
249+
const contentType = error.headers.get('Content-Type') || '';
250+
const isJson = contentType.includes('application/json');
251+
254252
const errorBody = isJson
255253
? await error.json().catch(() => null)
256254
: await error.text().catch(() => null);
257-
255+
258256
return [
259257
`${error.status} - ${error.statusText}`,
260258
errorBody?.error,
261-
errorBody?.reason || (typeof errorBody === "string" ? errorBody : null),
259+
errorBody?.reason ||
260+
(typeof errorBody === 'string' ? errorBody : null),
262261
]
263262
.filter(Boolean)
264-
.join(": ");
263+
.join(': ');
265264
}
266-
267-
return error?.message || "An unknown error occurred.";
265+
266+
return error?.message || 'An unknown error occurred.';
268267
})();
269-
270-
setError({ errorMessage });
271-
268+
269+
const downloaderError: ErrorMessage = { errorMessage };
270+
if (errorResponse) downloaderError.errorResponse = errorResponse;
271+
setInternalError(downloaderError);
272+
272273
clearTimeout(timeoutId);
273274
clearInterval(intervalId);
274275
});
275-
276276
},
277277
[
278278
isInProgress,
@@ -285,24 +285,30 @@ export default function useDownloader(
285285
]
286286
);
287287

288-
return useMemo(
288+
const downloadState = useMemo(
289289
() => ({
290290
elapsed,
291291
percentage,
292292
size,
293+
error: internalError,
294+
isInProgress,
295+
}),
296+
[elapsed, percentage, size, internalError, isInProgress]
297+
);
298+
299+
const downloadActions = useMemo(
300+
() => ({
293301
download: handleDownload,
294302
cancel: closeControllerCallback,
295-
error,
296-
isInProgress,
297303
}),
298-
[
299-
elapsed,
300-
percentage,
301-
size,
302-
handleDownload,
303-
closeControllerCallback,
304-
error,
305-
isInProgress,
306-
]
304+
[handleDownload, closeControllerCallback]
305+
);
306+
307+
return useMemo(
308+
() => ({
309+
...downloadState,
310+
...downloadActions,
311+
}),
312+
[downloadState, downloadActions]
307313
);
308314
}

src/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { SetStateAction } from 'react';
22

33
export type ErrorMessage = {
44
errorMessage: string;
5+
errorResponse?: Response;
56
} | null;
67

78
/** useDownloader options for fetch call
@@ -89,6 +90,6 @@ interface CustomURL extends URL {
8990

9091
export interface WindowDownloaderEmbedded extends Window {
9192
navigator: CustomNavigator;
92-
URL?: CustomURL
93-
webkitURL?: CustomURL
93+
URL?: CustomURL;
94+
webkitURL?: CustomURL;
9495
}

0 commit comments

Comments
 (0)