├── .eslintrc.js
├── .gitignore
├── .npmignore
├── .yarnrc.yml
├── CHANGELOG.md
├── README.md
├── babel.config.js
├── component.jsx
├── dist
├── use-async-queue.d.ts
├── use-async-queue.js
├── use-async-queue.js.map
├── use-async-queue.modern.mjs
├── use-async-queue.modern.mjs.map
├── use-async-queue.module.js
├── use-async-queue.module.js.map
├── use-async-queue.umd.js
└── use-async-queue.umd.js.map
├── jest.config.js
├── jsconfig.json
├── package.json
├── tsconfig.json
├── use-async-queue.test.jsx
├── use-async-queue.ts
└── yarn.lock
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es6: true,
5 | "jest/globals": true,
6 | },
7 | extends: ["eslint:recommended", "plugin:prettier/recommended"],
8 | globals: {
9 | Atomics: "readonly",
10 | SharedArrayBuffer: "readonly",
11 | },
12 | parserOptions: {
13 | ecmaFeatures: {
14 | jsx: true,
15 | },
16 | ecmaVersion: 2018,
17 | sourceType: "module",
18 | },
19 | plugins: ["react", "jest", "react-hooks"],
20 | rules: {
21 | "react-hooks/rules-of-hooks": "error",
22 | "react-hooks/exhaustive-deps": "warn",
23 | },
24 | overrides: [
25 | {
26 | files: ["*.ts"],
27 | plugins: ["@typescript-eslint"],
28 | extends: ["plugin:@typescript-eslint/recommended"],
29 | parser: "@typescript-eslint/parser",
30 | },
31 | ],
32 | };
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | demo.js
2 | node_modules
3 | local*
4 | *.el
5 | .rts*
6 | TAGS
7 |
8 | .nvmrc
9 | .envrc
10 | .yarn
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | demo.js
2 | local*
3 | *.el
4 | use-async-queue.ts
5 | .rts*
6 | TAGS
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [3.0.0] - 2024-11-21
4 |
5 | ### Changed
6 |
7 | - **Breaking:** Duplication prevention: when `add` is called with a task that
8 | has an `id` equal to the `id` of a pending or in-flight tasks, the task will
9 | not be added to the queue.
10 | - Equality is checked with `===`, so `id`s should be primitives.
11 | - This implies that only one task with undefined `id` can be in the queue at
12 | a time, essentially making `id` a required field.
13 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # use-async-queue
2 |
3 | A React Hook implementing a queue for sync or async tasks, with optional
4 | concurrency limit.
5 |
6 | Inspired by
7 | [@caolan/async.queue](http://caolan.github.io/async/docs.html#queue).
8 |
9 | ## Usage
10 |
11 | - Create a queue with some concurrency. Default concurrency is 8. Set to
12 | `Infinity` or less than 1 for no concurrency limit.
13 | - Register for notifications as tasks are processed and finished.
14 | - Add tasks to it. A task is an object with an `id` (some unique primitive
15 | value that makes sense for your use case -- a number, a url, etc.) and a
16 | `task` (a function that returns a Promise).
17 | - If `add` is called with a task that has an `id` equal to the `id` of a
18 | pending or in-flight tasks, it will not be added to the queue.
19 | - **Demo: https://codesandbox.io/s/use-async-queue-demo-53y89**
20 |
21 | ```javascript
22 | import useAsyncQueue from 'use-async-queue';
23 |
24 | // Example shows a task fetching a url, but a task can be any operation.
25 | const url = 'some url';
26 |
27 | const inflight = task => {
28 | console.log(`starting ${task.id}`);
29 | console.dir(stats); // { numPending: 0, numInFlight: 1, numDone: 0}
30 | };
31 |
32 | const done = async task => {
33 | const result = await task.result;
34 | console.log(`finished ${task.id}: ${result}`);
35 | console.dir(stats); // { numPending: 0, numInFlight: 0, numDone: 1}
36 | };
37 |
38 | const drain = () => {
39 | console.log('all done');
40 | console.dir(stats); // { numPending: 0, numInFlight: 0, numDone: 1}
41 | };
42 |
43 | const { add, stats } = useAsyncQueue({
44 | concurrency: 1,
45 | inflight,
46 | done,
47 | drain,
48 | });
49 |
50 | add({
51 | id: url,
52 | task: () => {
53 | return fetch(url).then(res => res.text());
54 | },
55 | });
56 | console.dir(stats); // { numPending: 1, numInFlight: 0, numDone: 0}
57 | ```
58 |
59 | ## TODO
60 |
61 | - [x] return numInFlight, numRemaining, numDone
62 | - [x] catch
63 | - [x] pending/inflight
64 | - [x] inflight callback
65 | - [x] drain callback
66 | - [ ] timeouts
67 | - [ ] start, stop methods
68 | - [ ] use events instead of/in addition to callbacks
69 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | // babel.config.js
2 | module.exports = {
3 | presets: [
4 | [
5 | "@babel/preset-env",
6 | {
7 | targets: {
8 | node: "current",
9 | },
10 | },
11 | ],
12 | ["@babel/preset-react", { development: true, runtime: "automatic" }],
13 | ],
14 | };
15 |
--------------------------------------------------------------------------------
/component.jsx:
--------------------------------------------------------------------------------
1 | import useAsyncQueue from "./dist/use-async-queue";
2 | import { useState, useEffect } from "react";
3 |
4 | const makeTask = (id, delay) => {
5 | return {
6 | id,
7 | task: () => {
8 | return new Promise((resolve) =>
9 | setTimeout(() => {
10 | resolve(`${id} is done`);
11 | }, delay)
12 | );
13 | },
14 | };
15 | };
16 |
17 | export const Component = ({ items }) => {
18 | const [doneItems, setDoneItems] = useState([]);
19 |
20 | const done = (d) => {
21 | setDoneItems((items) => [...items, d]);
22 | };
23 |
24 | const { add, stats } = useAsyncQueue({ concurrency: 2, done });
25 |
26 | useEffect(() => {
27 | items.forEach((item) => add(makeTask(item.id, item.delay)));
28 | }, [items, add]);
29 | const { numPending, numInFlight, numDone } = stats;
30 |
31 | return (
32 | <>
33 | pending: {numPending}
34 | inFlight: {numInFlight}
35 | done: {numDone}
36 | total: {numPending + numInFlight + numDone}
37 | {doneItems.map((item) => (
38 |
item done - {item.id}
39 | ))}
40 | >
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/dist/use-async-queue.d.ts:
--------------------------------------------------------------------------------
1 | interface QueueStats {
2 | numPending: number;
3 | numInFlight: number;
4 | numDone: number;
5 | }
6 | export interface QueueTaskResult {
7 | id: unknown;
8 | task(): Promise;
9 | result?: Promise;
10 | stats?: QueueStats;
11 | }
12 | interface Queue {
13 | add: (task: QueueTaskResult) => void;
14 | stats: QueueStats;
15 | }
16 | interface QueueOpts {
17 | concurrency?: number;
18 | done?: (result: QueueTaskResult) => void;
19 | drain?: () => void;
20 | inflight?: (task: QueueTaskResult) => void;
21 | }
22 | declare function useAsyncQueue(opts: QueueOpts): Queue;
23 | export default useAsyncQueue;
24 |
--------------------------------------------------------------------------------
/dist/use-async-queue.js:
--------------------------------------------------------------------------------
1 | var n=require("react");function t(n){return n&&"object"==typeof n&&"default"in n?n:{default:n}}var e=/*#__PURE__*/t(require("next-tick"));module.exports=function(t){const{done:u,drain:r,inflight:i}=t;let{concurrency:c}=t;c=c||Infinity,c<1&&(c=Infinity);const[s,l]=n.useState({numPending:0,numInFlight:0,numDone:0}),o=n.useRef(!0),f=n.useRef([]),d=n.useRef([]);return n.useEffect(()=>{if(s.numDone>0&&r&&0===f.current.length&&0===d.current.length&&!o.current)return o.current=!0,e.default(r);for(;f.current.length0;){o.current=!1;const n=d.current.shift();if(n){f.current.push(n),l(n=>({...n,numPending:n.numPending-1,numInFlight:n.numInFlight+1})),i&&i({...n,stats:s});const t=n.task();t.then(()=>{f.current.pop(),l(n=>({...n,numInFlight:n.numInFlight-1,numDone:n.numDone+1})),u&&u({...n,result:t,stats:s})}).catch(()=>{f.current.pop(),l(n=>({...n,numInFlight:n.numInFlight-1,numDone:n.numDone+1})),u&&u({...n,result:t,stats:s})})}}},[c,u,r,i,s]),{add:n.useCallback(n=>{d.current.find(t=>t.id===n.id)||f.current.find(t=>t.id===n.id)||(d.current.push(n),l(n=>({...n,numPending:n.numPending+1})))},[]),stats:s}};
2 | //# sourceMappingURL=use-async-queue.js.map
3 |
--------------------------------------------------------------------------------
/dist/use-async-queue.js.map:
--------------------------------------------------------------------------------
1 | {"version":3,"file":"use-async-queue.js","sources":["../use-async-queue.ts"],"sourcesContent":["import { useState, useRef, useCallback, useEffect } from \"react\";\nimport nextTick from \"next-tick\";\n\ninterface QueueStats {\n numPending: number;\n numInFlight: number;\n numDone: number;\n}\n\nexport interface QueueTaskResult {\n id: unknown;\n task(): Promise;\n result?: Promise;\n stats?: QueueStats;\n}\n\ninterface Queue {\n add: (task: QueueTaskResult) => void;\n stats: QueueStats;\n}\n\ninterface QueueOpts {\n concurrency?: number;\n done?: (result: QueueTaskResult) => void;\n drain?: () => void;\n inflight?: (task: QueueTaskResult) => void;\n}\n\nfunction useAsyncQueue(opts: QueueOpts): Queue {\n const { done, drain, inflight } = opts;\n let { concurrency } = opts;\n concurrency = concurrency || Infinity;\n if (concurrency < 1) concurrency = Infinity;\n\n const [stats, setStats] = useState({\n numPending: 0,\n numInFlight: 0,\n numDone: 0,\n });\n\n const drained = useRef(true);\n const inFlight = useRef([] as QueueTaskResult[]);\n const pending = useRef([] as QueueTaskResult[]);\n\n useEffect(() => {\n if (\n stats.numDone > 0 &&\n drain &&\n inFlight.current.length === 0 &&\n pending.current.length === 0 &&\n !drained.current\n ) {\n drained.current = true;\n return nextTick(drain);\n }\n\n while (\n inFlight.current.length < concurrency! &&\n pending.current.length > 0\n ) {\n drained.current = false;\n const task = pending.current.shift();\n if (task) {\n inFlight.current.push(task);\n setStats((stats) => {\n return {\n ...stats,\n numPending: stats.numPending - 1,\n numInFlight: stats.numInFlight + 1,\n };\n });\n inflight && inflight({ ...task, stats });\n const result = task.task();\n result\n .then(() => {\n inFlight.current.pop();\n setStats((stats) => {\n return {\n ...stats,\n numInFlight: stats.numInFlight - 1,\n numDone: stats.numDone + 1,\n };\n });\n done && done({ ...task, result, stats });\n })\n .catch(() => {\n inFlight.current.pop();\n setStats((stats) => {\n return {\n ...stats,\n numInFlight: stats.numInFlight - 1,\n numDone: stats.numDone + 1,\n };\n });\n done && done({ ...task, result, stats });\n });\n }\n }\n }, [concurrency, done, drain, inflight, stats]);\n\n const add = useCallback((task: QueueTaskResult) => {\n if (\n !pending.current.find((t) => {\n return t.id === task.id;\n }) &&\n !inFlight.current.find((t) => {\n return t.id === task.id;\n })\n ) {\n pending.current.push(task);\n setStats((stats) => {\n return {\n ...stats,\n numPending: stats.numPending + 1,\n };\n });\n }\n }, []);\n\n return { add, stats };\n}\n\nexport default useAsyncQueue;\n"],"names":["opts","done","drain","inflight","concurrency","Infinity","stats","setStats","useState","numPending","numInFlight","numDone","drained","useRef","inFlight","pending","useEffect","current","length","nextTick","task","shift","push","result","then","pop","catch","add","useCallback","find","t","id"],"mappings":"yJA4BA,SAAuBA,GACrB,MAAMC,KAAEA,EAAIC,MAAEA,EAAKC,SAAEA,GAAaH,EAClC,IAAII,YAAEA,GAAgBJ,EACtBI,EAAcA,GAAeC,SACzBD,EAAc,IAAGA,EAAcC,UAEnC,MAAOC,EAAOC,GAAYC,EAAQA,SAAC,CACjCC,WAAY,EACZC,YAAa,EACbC,QAAS,IAGLC,EAAUC,EAAAA,QAAO,GACjBC,EAAWD,EAAMA,OAAC,IAClBE,EAAUF,EAAAA,OAAO,IA6EvB,OA3EAG,EAAAA,UAAU,KACR,GACEV,EAAMK,QAAU,GAChBT,GAC4B,IAA5BY,EAASG,QAAQC,QACU,IAA3BH,EAAQE,QAAQC,SACfN,EAAQK,QAGT,OADAL,EAAQK,SAAU,EACXE,EAAQ,QAACjB,GAGlB,KACEY,EAASG,QAAQC,OAASd,GAC1BW,EAAQE,QAAQC,OAAS,GACzB,CACAN,EAAQK,SAAU,EAClB,MAAMG,EAAOL,EAAQE,QAAQI,QAC7B,GAAID,EAAM,CACRN,EAASG,QAAQK,KAAKF,GACtBb,EAAUD,IACD,IACFA,EACHG,WAAYH,EAAMG,WAAa,EAC/BC,YAAaJ,EAAMI,YAAc,KAGrCP,GAAYA,EAAS,IAAKiB,EAAMd,UAChC,MAAMiB,EAASH,EAAKA,OACpBG,EACGC,KAAK,KACJV,EAASG,QAAQQ,MACjBlB,EAAUD,IACD,IACFA,EACHI,YAAaJ,EAAMI,YAAc,EACjCC,QAASL,EAAMK,QAAU,KAG7BV,GAAQA,EAAK,IAAKmB,EAAMG,SAAQjB,SAClC,GACCoB,MAAM,KACLZ,EAASG,QAAQQ,MACjBlB,EAAUD,IACD,IACFA,EACHI,YAAaJ,EAAMI,YAAc,EACjCC,QAASL,EAAMK,QAAU,KAG7BV,GAAQA,EAAK,IAAKmB,EAAMG,SAAQjB,WAEtC,CACF,GACC,CAACF,EAAaH,EAAMC,EAAOC,EAAUG,IAqBjC,CAAEqB,IAnBGC,EAAAA,YAAaR,IAEpBL,EAAQE,QAAQY,KAAMC,GACdA,EAAEC,KAAOX,EAAKW,KAEtBjB,EAASG,QAAQY,KAAMC,GACfA,EAAEC,KAAOX,EAAKW,MAGvBhB,EAAQE,QAAQK,KAAKF,GACrBb,EAAUD,IACD,IACFA,EACHG,WAAYH,EAAMG,WAAa,KAGrC,EACC,IAEWH,QAChB"}
--------------------------------------------------------------------------------
/dist/use-async-queue.modern.mjs:
--------------------------------------------------------------------------------
1 | import{useState as n,useRef as t,useEffect as r,useCallback as e}from"react";import u from"next-tick";function i(){return i=Object.assign?Object.assign.bind():function(n){for(var t=1;t{if(g.numDone>0&&s&&0===d.current.length&&0===f.current.length&&!h.current)return h.current=!0,u(s);for(;d.current.length0;){h.current=!1;const n=f.current.shift();if(n){d.current.push(n),a(n=>i({},n,{numPending:n.numPending-1,numInFlight:n.numInFlight+1})),l&&l(i({},n,{stats:g}));const t=n.task();t.then(()=>{d.current.pop(),a(n=>i({},n,{numInFlight:n.numInFlight-1,numDone:n.numDone+1})),o&&o(i({},n,{result:t,stats:g}))}).catch(()=>{d.current.pop(),a(n=>i({},n,{numInFlight:n.numInFlight-1,numDone:n.numDone+1})),o&&o(i({},n,{result:t,stats:g}))})}}},[m,o,s,l,g]);const p=e(n=>{f.current.find(t=>t.id===n.id)||d.current.find(t=>t.id===n.id)||(f.current.push(n),a(n=>i({},n,{numPending:n.numPending+1})))},[]);return{add:p,stats:g}}export{c as default};
2 | //# sourceMappingURL=use-async-queue.modern.mjs.map
3 |
--------------------------------------------------------------------------------
/dist/use-async-queue.modern.mjs.map:
--------------------------------------------------------------------------------
1 | {"version":3,"file":"use-async-queue.modern.mjs","sources":["../use-async-queue.ts"],"sourcesContent":["import { useState, useRef, useCallback, useEffect } from \"react\";\nimport nextTick from \"next-tick\";\n\ninterface QueueStats {\n numPending: number;\n numInFlight: number;\n numDone: number;\n}\n\nexport interface QueueTaskResult {\n id: unknown;\n task(): Promise;\n result?: Promise;\n stats?: QueueStats;\n}\n\ninterface Queue {\n add: (task: QueueTaskResult) => void;\n stats: QueueStats;\n}\n\ninterface QueueOpts {\n concurrency?: number;\n done?: (result: QueueTaskResult) => void;\n drain?: () => void;\n inflight?: (task: QueueTaskResult) => void;\n}\n\nfunction useAsyncQueue(opts: QueueOpts): Queue {\n const { done, drain, inflight } = opts;\n let { concurrency } = opts;\n concurrency = concurrency || Infinity;\n if (concurrency < 1) concurrency = Infinity;\n\n const [stats, setStats] = useState({\n numPending: 0,\n numInFlight: 0,\n numDone: 0,\n });\n\n const drained = useRef(true);\n const inFlight = useRef([] as QueueTaskResult[]);\n const pending = useRef([] as QueueTaskResult[]);\n\n useEffect(() => {\n if (\n stats.numDone > 0 &&\n drain &&\n inFlight.current.length === 0 &&\n pending.current.length === 0 &&\n !drained.current\n ) {\n drained.current = true;\n return nextTick(drain);\n }\n\n while (\n inFlight.current.length < concurrency! &&\n pending.current.length > 0\n ) {\n drained.current = false;\n const task = pending.current.shift();\n if (task) {\n inFlight.current.push(task);\n setStats((stats) => {\n return {\n ...stats,\n numPending: stats.numPending - 1,\n numInFlight: stats.numInFlight + 1,\n };\n });\n inflight && inflight({ ...task, stats });\n const result = task.task();\n result\n .then(() => {\n inFlight.current.pop();\n setStats((stats) => {\n return {\n ...stats,\n numInFlight: stats.numInFlight - 1,\n numDone: stats.numDone + 1,\n };\n });\n done && done({ ...task, result, stats });\n })\n .catch(() => {\n inFlight.current.pop();\n setStats((stats) => {\n return {\n ...stats,\n numInFlight: stats.numInFlight - 1,\n numDone: stats.numDone + 1,\n };\n });\n done && done({ ...task, result, stats });\n });\n }\n }\n }, [concurrency, done, drain, inflight, stats]);\n\n const add = useCallback((task: QueueTaskResult) => {\n if (\n !pending.current.find((t) => {\n return t.id === task.id;\n }) &&\n !inFlight.current.find((t) => {\n return t.id === task.id;\n })\n ) {\n pending.current.push(task);\n setStats((stats) => {\n return {\n ...stats,\n numPending: stats.numPending + 1,\n };\n });\n }\n }, []);\n\n return { add, stats };\n}\n\nexport default useAsyncQueue;\n"],"names":["useAsyncQueue","opts","done","drain","inflight","concurrency","Infinity","stats","setStats","useState","numPending","numInFlight","numDone","drained","useRef","inFlight","pending","useEffect","current","length","nextTick","task","shift","push","_extends","result","then","pop","catch","add","useCallback","find","t","id"],"mappings":"8TA4BA,SAASA,EAAcC,GACrB,MAAMC,KAAEA,EAAIC,MAAEA,EAAKC,SAAEA,GAAaH,EAClC,IAAII,YAAEA,GAAgBJ,EACtBI,EAAcA,GAAeC,SACzBD,EAAc,IAAGA,EAAcC,UAEnC,MAAOC,EAAOC,GAAYC,EAAS,CACjCC,WAAY,EACZC,YAAa,EACbC,QAAS,IAGLC,EAAUC,GAAO,GACjBC,EAAWD,EAAO,IAClBE,EAAUF,EAAO,IAEvBG,EAAU,KACR,GACEV,EAAMK,QAAU,GAChBT,GAC4B,IAA5BY,EAASG,QAAQC,QACU,IAA3BH,EAAQE,QAAQC,SACfN,EAAQK,QAGT,OADAL,EAAQK,SAAU,EACXE,EAASjB,GAGlB,KACEY,EAASG,QAAQC,OAASd,GAC1BW,EAAQE,QAAQC,OAAS,GACzB,CACAN,EAAQK,SAAU,EAClB,MAAMG,EAAOL,EAAQE,QAAQI,QAC7B,GAAID,EAAM,CACRN,EAASG,QAAQK,KAAKF,GACtBb,EAAUD,GACRiB,EAAA,CAAA,EACKjB,EACHG,CAAAA,WAAYH,EAAMG,WAAa,EAC/BC,YAAaJ,EAAMI,YAAc,KAGrCP,GAAYA,EAAQoB,EAAA,CAAA,EAAMH,EAAI,CAAEd,WAChC,MAAMkB,EAASJ,EAAKA,OACpBI,EACGC,KAAK,KACJX,EAASG,QAAQS,MACjBnB,EAAUD,GACRiB,EACKjB,CAAAA,EAAAA,EACHI,CAAAA,YAAaJ,EAAMI,YAAc,EACjCC,QAASL,EAAMK,QAAU,KAG7BV,GAAQA,EAAIsB,EAAA,CAAA,EAAMH,EAAMI,CAAAA,SAAQlB,aAEjCqB,MAAM,KACLb,EAASG,QAAQS,MACjBnB,EAAUD,GACRiB,EAAA,CAAA,EACKjB,EACHI,CAAAA,YAAaJ,EAAMI,YAAc,EACjCC,QAASL,EAAMK,QAAU,KAG7BV,GAAQA,EAAIsB,EAAA,CAAA,EAAMH,EAAI,CAAEI,SAAQlB,UAClC,EACJ,CACF,GACC,CAACF,EAAaH,EAAMC,EAAOC,EAAUG,IAExC,MAAMsB,EAAMC,EAAaT,IAEpBL,EAAQE,QAAQa,KAAMC,GACdA,EAAEC,KAAOZ,EAAKY,KAEtBlB,EAASG,QAAQa,KAAMC,GACfA,EAAEC,KAAOZ,EAAKY,MAGvBjB,EAAQE,QAAQK,KAAKF,GACrBb,EAAUD,GACRiB,EACKjB,CAAAA,EAAAA,EACHG,CAAAA,WAAYH,EAAMG,WAAa,KAGrC,EACC,IAEH,MAAO,CAAEmB,MAAKtB,QAChB"}
--------------------------------------------------------------------------------
/dist/use-async-queue.module.js:
--------------------------------------------------------------------------------
1 | import{useState as n,useRef as t,useEffect as r,useCallback as e}from"react";import u from"next-tick";function i(i){const{done:c,drain:m,inflight:o}=i;let{concurrency:s}=i;s=s||Infinity,s<1&&(s=Infinity);const[g,h]=n({numPending:0,numInFlight:0,numDone:0}),d=t(!0),l=t([]),f=t([]);r(()=>{if(g.numDone>0&&m&&0===l.current.length&&0===f.current.length&&!d.current)return d.current=!0,u(m);for(;l.current.length0;){d.current=!1;const n=f.current.shift();if(n){l.current.push(n),h(n=>({...n,numPending:n.numPending-1,numInFlight:n.numInFlight+1})),o&&o({...n,stats:g});const t=n.task();t.then(()=>{l.current.pop(),h(n=>({...n,numInFlight:n.numInFlight-1,numDone:n.numDone+1})),c&&c({...n,result:t,stats:g})}).catch(()=>{l.current.pop(),h(n=>({...n,numInFlight:n.numInFlight-1,numDone:n.numDone+1})),c&&c({...n,result:t,stats:g})})}}},[s,c,m,o,g]);const a=e(n=>{f.current.find(t=>t.id===n.id)||l.current.find(t=>t.id===n.id)||(f.current.push(n),h(n=>({...n,numPending:n.numPending+1})))},[]);return{add:a,stats:g}}export{i as default};
2 | //# sourceMappingURL=use-async-queue.module.js.map
3 |
--------------------------------------------------------------------------------
/dist/use-async-queue.module.js.map:
--------------------------------------------------------------------------------
1 | {"version":3,"file":"use-async-queue.module.js","sources":["../use-async-queue.ts"],"sourcesContent":["import { useState, useRef, useCallback, useEffect } from \"react\";\nimport nextTick from \"next-tick\";\n\ninterface QueueStats {\n numPending: number;\n numInFlight: number;\n numDone: number;\n}\n\nexport interface QueueTaskResult {\n id: unknown;\n task(): Promise;\n result?: Promise;\n stats?: QueueStats;\n}\n\ninterface Queue {\n add: (task: QueueTaskResult) => void;\n stats: QueueStats;\n}\n\ninterface QueueOpts {\n concurrency?: number;\n done?: (result: QueueTaskResult) => void;\n drain?: () => void;\n inflight?: (task: QueueTaskResult) => void;\n}\n\nfunction useAsyncQueue(opts: QueueOpts): Queue {\n const { done, drain, inflight } = opts;\n let { concurrency } = opts;\n concurrency = concurrency || Infinity;\n if (concurrency < 1) concurrency = Infinity;\n\n const [stats, setStats] = useState({\n numPending: 0,\n numInFlight: 0,\n numDone: 0,\n });\n\n const drained = useRef(true);\n const inFlight = useRef([] as QueueTaskResult[]);\n const pending = useRef([] as QueueTaskResult[]);\n\n useEffect(() => {\n if (\n stats.numDone > 0 &&\n drain &&\n inFlight.current.length === 0 &&\n pending.current.length === 0 &&\n !drained.current\n ) {\n drained.current = true;\n return nextTick(drain);\n }\n\n while (\n inFlight.current.length < concurrency! &&\n pending.current.length > 0\n ) {\n drained.current = false;\n const task = pending.current.shift();\n if (task) {\n inFlight.current.push(task);\n setStats((stats) => {\n return {\n ...stats,\n numPending: stats.numPending - 1,\n numInFlight: stats.numInFlight + 1,\n };\n });\n inflight && inflight({ ...task, stats });\n const result = task.task();\n result\n .then(() => {\n inFlight.current.pop();\n setStats((stats) => {\n return {\n ...stats,\n numInFlight: stats.numInFlight - 1,\n numDone: stats.numDone + 1,\n };\n });\n done && done({ ...task, result, stats });\n })\n .catch(() => {\n inFlight.current.pop();\n setStats((stats) => {\n return {\n ...stats,\n numInFlight: stats.numInFlight - 1,\n numDone: stats.numDone + 1,\n };\n });\n done && done({ ...task, result, stats });\n });\n }\n }\n }, [concurrency, done, drain, inflight, stats]);\n\n const add = useCallback((task: QueueTaskResult) => {\n if (\n !pending.current.find((t) => {\n return t.id === task.id;\n }) &&\n !inFlight.current.find((t) => {\n return t.id === task.id;\n })\n ) {\n pending.current.push(task);\n setStats((stats) => {\n return {\n ...stats,\n numPending: stats.numPending + 1,\n };\n });\n }\n }, []);\n\n return { add, stats };\n}\n\nexport default useAsyncQueue;\n"],"names":["useAsyncQueue","opts","done","drain","inflight","concurrency","Infinity","stats","setStats","useState","numPending","numInFlight","numDone","drained","useRef","inFlight","pending","useEffect","current","length","nextTick","task","shift","push","result","then","pop","catch","add","useCallback","find","t","id"],"mappings":"sGA4BA,SAASA,EAAcC,GACrB,MAAMC,KAAEA,EAAIC,MAAEA,EAAKC,SAAEA,GAAaH,EAClC,IAAII,YAAEA,GAAgBJ,EACtBI,EAAcA,GAAeC,SACzBD,EAAc,IAAGA,EAAcC,UAEnC,MAAOC,EAAOC,GAAYC,EAAS,CACjCC,WAAY,EACZC,YAAa,EACbC,QAAS,IAGLC,EAAUC,GAAO,GACjBC,EAAWD,EAAO,IAClBE,EAAUF,EAAO,IAEvBG,EAAU,KACR,GACEV,EAAMK,QAAU,GAChBT,GAC4B,IAA5BY,EAASG,QAAQC,QACU,IAA3BH,EAAQE,QAAQC,SACfN,EAAQK,QAGT,OADAL,EAAQK,SAAU,EACXE,EAASjB,GAGlB,KACEY,EAASG,QAAQC,OAASd,GAC1BW,EAAQE,QAAQC,OAAS,GACzB,CACAN,EAAQK,SAAU,EAClB,MAAMG,EAAOL,EAAQE,QAAQI,QAC7B,GAAID,EAAM,CACRN,EAASG,QAAQK,KAAKF,GACtBb,EAAUD,IACD,IACFA,EACHG,WAAYH,EAAMG,WAAa,EAC/BC,YAAaJ,EAAMI,YAAc,KAGrCP,GAAYA,EAAS,IAAKiB,EAAMd,UAChC,MAAMiB,EAASH,EAAKA,OACpBG,EACGC,KAAK,KACJV,EAASG,QAAQQ,MACjBlB,EAAUD,IACD,IACFA,EACHI,YAAaJ,EAAMI,YAAc,EACjCC,QAASL,EAAMK,QAAU,KAG7BV,GAAQA,EAAK,IAAKmB,EAAMG,SAAQjB,SAClC,GACCoB,MAAM,KACLZ,EAASG,QAAQQ,MACjBlB,EAAUD,IACD,IACFA,EACHI,YAAaJ,EAAMI,YAAc,EACjCC,QAASL,EAAMK,QAAU,KAG7BV,GAAQA,EAAK,IAAKmB,EAAMG,SAAQjB,WAEtC,CACF,GACC,CAACF,EAAaH,EAAMC,EAAOC,EAAUG,IAExC,MAAMqB,EAAMC,EAAaR,IAEpBL,EAAQE,QAAQY,KAAMC,GACdA,EAAEC,KAAOX,EAAKW,KAEtBjB,EAASG,QAAQY,KAAMC,GACfA,EAAEC,KAAOX,EAAKW,MAGvBhB,EAAQE,QAAQK,KAAKF,GACrBb,EAAUD,IACD,IACFA,EACHG,WAAYH,EAAMG,WAAa,KAGrC,EACC,IAEH,MAAO,CAAEkB,MAAKrB,QAChB"}
--------------------------------------------------------------------------------
/dist/use-async-queue.umd.js:
--------------------------------------------------------------------------------
1 | !function(n,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("react"),require("next-tick")):"function"==typeof define&&define.amd?define(["react","next-tick"],e):(n||self).useAsyncQueue=e(n.react,n.nextTick)}(this,function(n,e){function t(n){return n&&"object"==typeof n&&"default"in n?n:{default:n}}var u=/*#__PURE__*/t(e);return function(e){const{done:t,drain:r,inflight:i}=e;let{concurrency:c}=e;c=c||Infinity,c<1&&(c=Infinity);const[f,o]=n.useState({numPending:0,numInFlight:0,numDone:0}),s=n.useRef(!0),d=n.useRef([]),l=n.useRef([]);return n.useEffect(()=>{if(f.numDone>0&&r&&0===d.current.length&&0===l.current.length&&!s.current)return s.current=!0,u.default(r);for(;d.current.length0;){s.current=!1;const n=l.current.shift();if(n){d.current.push(n),o(n=>({...n,numPending:n.numPending-1,numInFlight:n.numInFlight+1})),i&&i({...n,stats:f});const e=n.task();e.then(()=>{d.current.pop(),o(n=>({...n,numInFlight:n.numInFlight-1,numDone:n.numDone+1})),t&&t({...n,result:e,stats:f})}).catch(()=>{d.current.pop(),o(n=>({...n,numInFlight:n.numInFlight-1,numDone:n.numDone+1})),t&&t({...n,result:e,stats:f})})}}},[c,t,r,i,f]),{add:n.useCallback(n=>{l.current.find(e=>e.id===n.id)||d.current.find(e=>e.id===n.id)||(l.current.push(n),o(n=>({...n,numPending:n.numPending+1})))},[]),stats:f}}});
2 | //# sourceMappingURL=use-async-queue.umd.js.map
3 |
--------------------------------------------------------------------------------
/dist/use-async-queue.umd.js.map:
--------------------------------------------------------------------------------
1 | {"version":3,"file":"use-async-queue.umd.js","sources":["../use-async-queue.ts"],"sourcesContent":["import { useState, useRef, useCallback, useEffect } from \"react\";\nimport nextTick from \"next-tick\";\n\ninterface QueueStats {\n numPending: number;\n numInFlight: number;\n numDone: number;\n}\n\nexport interface QueueTaskResult {\n id: unknown;\n task(): Promise;\n result?: Promise;\n stats?: QueueStats;\n}\n\ninterface Queue {\n add: (task: QueueTaskResult) => void;\n stats: QueueStats;\n}\n\ninterface QueueOpts {\n concurrency?: number;\n done?: (result: QueueTaskResult) => void;\n drain?: () => void;\n inflight?: (task: QueueTaskResult) => void;\n}\n\nfunction useAsyncQueue(opts: QueueOpts): Queue {\n const { done, drain, inflight } = opts;\n let { concurrency } = opts;\n concurrency = concurrency || Infinity;\n if (concurrency < 1) concurrency = Infinity;\n\n const [stats, setStats] = useState({\n numPending: 0,\n numInFlight: 0,\n numDone: 0,\n });\n\n const drained = useRef(true);\n const inFlight = useRef([] as QueueTaskResult[]);\n const pending = useRef([] as QueueTaskResult[]);\n\n useEffect(() => {\n if (\n stats.numDone > 0 &&\n drain &&\n inFlight.current.length === 0 &&\n pending.current.length === 0 &&\n !drained.current\n ) {\n drained.current = true;\n return nextTick(drain);\n }\n\n while (\n inFlight.current.length < concurrency! &&\n pending.current.length > 0\n ) {\n drained.current = false;\n const task = pending.current.shift();\n if (task) {\n inFlight.current.push(task);\n setStats((stats) => {\n return {\n ...stats,\n numPending: stats.numPending - 1,\n numInFlight: stats.numInFlight + 1,\n };\n });\n inflight && inflight({ ...task, stats });\n const result = task.task();\n result\n .then(() => {\n inFlight.current.pop();\n setStats((stats) => {\n return {\n ...stats,\n numInFlight: stats.numInFlight - 1,\n numDone: stats.numDone + 1,\n };\n });\n done && done({ ...task, result, stats });\n })\n .catch(() => {\n inFlight.current.pop();\n setStats((stats) => {\n return {\n ...stats,\n numInFlight: stats.numInFlight - 1,\n numDone: stats.numDone + 1,\n };\n });\n done && done({ ...task, result, stats });\n });\n }\n }\n }, [concurrency, done, drain, inflight, stats]);\n\n const add = useCallback((task: QueueTaskResult) => {\n if (\n !pending.current.find((t) => {\n return t.id === task.id;\n }) &&\n !inFlight.current.find((t) => {\n return t.id === task.id;\n })\n ) {\n pending.current.push(task);\n setStats((stats) => {\n return {\n ...stats,\n numPending: stats.numPending + 1,\n };\n });\n }\n }, []);\n\n return { add, stats };\n}\n\nexport default useAsyncQueue;\n"],"names":["opts","done","drain","inflight","concurrency","Infinity","stats","setStats","useState","numPending","numInFlight","numDone","drained","useRef","inFlight","pending","useEffect","current","length","nextTick","task","shift","push","result","then","pop","catch","add","useCallback","find","t","id"],"mappings":"uZA4BA,SAAuBA,GACrB,MAAMC,KAAEA,EAAIC,MAAEA,EAAKC,SAAEA,GAAaH,EAClC,IAAII,YAAEA,GAAgBJ,EACtBI,EAAcA,GAAeC,SACzBD,EAAc,IAAGA,EAAcC,UAEnC,MAAOC,EAAOC,GAAYC,EAAQA,SAAC,CACjCC,WAAY,EACZC,YAAa,EACbC,QAAS,IAGLC,EAAUC,EAAAA,QAAO,GACjBC,EAAWD,EAAMA,OAAC,IAClBE,EAAUF,EAAAA,OAAO,IA6EvB,OA3EAG,EAAAA,UAAU,KACR,GACEV,EAAMK,QAAU,GAChBT,GAC4B,IAA5BY,EAASG,QAAQC,QACU,IAA3BH,EAAQE,QAAQC,SACfN,EAAQK,QAGT,OADAL,EAAQK,SAAU,EACXE,EAAQ,QAACjB,GAGlB,KACEY,EAASG,QAAQC,OAASd,GAC1BW,EAAQE,QAAQC,OAAS,GACzB,CACAN,EAAQK,SAAU,EAClB,MAAMG,EAAOL,EAAQE,QAAQI,QAC7B,GAAID,EAAM,CACRN,EAASG,QAAQK,KAAKF,GACtBb,EAAUD,IACD,IACFA,EACHG,WAAYH,EAAMG,WAAa,EAC/BC,YAAaJ,EAAMI,YAAc,KAGrCP,GAAYA,EAAS,IAAKiB,EAAMd,UAChC,MAAMiB,EAASH,EAAKA,OACpBG,EACGC,KAAK,KACJV,EAASG,QAAQQ,MACjBlB,EAAUD,IACD,IACFA,EACHI,YAAaJ,EAAMI,YAAc,EACjCC,QAASL,EAAMK,QAAU,KAG7BV,GAAQA,EAAK,IAAKmB,EAAMG,SAAQjB,SAClC,GACCoB,MAAM,KACLZ,EAASG,QAAQQ,MACjBlB,EAAUD,IACD,IACFA,EACHI,YAAaJ,EAAMI,YAAc,EACjCC,QAASL,EAAMK,QAAU,KAG7BV,GAAQA,EAAK,IAAKmB,EAAMG,SAAQjB,WAEtC,CACF,GACC,CAACF,EAAaH,EAAMC,EAAOC,EAAUG,IAqBjC,CAAEqB,IAnBGC,EAAAA,YAAaR,IAEpBL,EAAQE,QAAQY,KAAMC,GACdA,EAAEC,KAAOX,EAAKW,KAEtBjB,EAASG,QAAQY,KAAMC,GACfA,EAAEC,KAAOX,EAAKW,MAGvBhB,EAAQE,QAAQK,KAAKF,GACrBb,EAAUD,IACD,IACFA,EACHG,WAAYH,EAAMG,WAAa,KAGrC,EACC,IAEWH,QAChB"}
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | testEnvironment: "jsdom",
3 | watchPathIgnorePatterns: ["/dist/"],
4 | transform: {
5 | "^.+\\.jsx?$": "babel-jest",
6 | },
7 | };
8 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2017",
4 | "allowSyntheticDefaultImports": true,
5 | "noEmit": true,
6 | "allowJs": true,
7 | "checkJs": false,
8 | "lib": [
9 | "dom",
10 | "es2017"
11 | ]
12 | },
13 | "exclude": [
14 | "local_notes",
15 | "node_modules",
16 | "indium",
17 | "notes"
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "use-async-queue",
3 | "version": "3.0.1",
4 | "source": "use-async-queue.ts",
5 | "main": "dist/use-async-queue.js",
6 | "exports": {
7 | "types": "./dist/use-async-queue.d.ts",
8 | "require": "./dist/use-async-queue.js",
9 | "default": "./dist/use-async-queue.modern.mjs"
10 | },
11 | "types": "dist/use-async-queue.d.ts",
12 | "module": "dist/use-async-queue.module.js",
13 | "unpkg": "dist/use-async-queue.umd.js",
14 | "author": "William Bert ",
15 | "url": "https://github.com/sandinmyjoints/use-async-queue",
16 | "homepage": "https://github.com/sandinmyjoints/use-async-queue",
17 | "bugs": {
18 | "url": "https://github.com/sandinmyjoints/use-async-queue/issues"
19 | },
20 | "license": "ISC",
21 | "keywords": [
22 | "react",
23 | "hook",
24 | "queue",
25 | "async",
26 | "concurrent"
27 | ],
28 | "scripts": {
29 | "build": "microbundle",
30 | "prerelease": "yarn run build",
31 | "preversion": "yarn run build",
32 | "release": "np",
33 | "dev": "microbundle watch",
34 | "lint": "eslint use-async-queue.*.js",
35 | "test": "jest --config=jest.config.js"
36 | },
37 | "devDependencies": {
38 | "@babel/core": "7.26.0",
39 | "@babel/preset-env": "7.26.0",
40 | "@babel/preset-react": "7.25.9",
41 | "@testing-library/dom": "10.4.0",
42 | "@testing-library/jest-dom": "6.6.3",
43 | "@testing-library/react": "16.0.1",
44 | "@types/jest": "25.2.3",
45 | "@types/next-tick": "1.0.0",
46 | "@types/react": "18.3.12",
47 | "@typescript-eslint/eslint-plugin": "5.5.0",
48 | "@typescript-eslint/parser": "5.5.0",
49 | "babel-jest": "29.7.0",
50 | "eslint": "8.57.1",
51 | "eslint-config-prettier": "8.10.0",
52 | "eslint-plugin-jest": "23.8.2",
53 | "eslint-plugin-prettier": "5.2.1",
54 | "eslint-plugin-react": "7.19.0",
55 | "eslint-plugin-react-hooks": "5.0.0",
56 | "jest": "29.7.0",
57 | "jest-environment-jsdom": "^29.7.0",
58 | "microbundle": "0.15.1",
59 | "np": "10.0.7",
60 | "prettier": "3.3.3",
61 | "react": "18.3.1",
62 | "react-dom": "18.3.1",
63 | "typescript": "5.6.3"
64 | },
65 | "peerDependencies": {
66 | "react": "18.3.1"
67 | },
68 | "dependencies": {
69 | "next-tick": "1.1.0"
70 | },
71 | "prettier": {
72 | "trailingComma": "es5",
73 | "arrowParens": "always"
74 | },
75 | "packageManager": "yarn@4.5.1"
76 | }
77 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Node 16",
4 |
5 | "compilerOptions": {
6 | "lib": ["es2020"],
7 | "module": "esnext",
8 | "target": "es2021",
9 |
10 | "strict": true,
11 | "esModuleInterop": true,
12 | "skipLibCheck": true,
13 | "forceConsistentCasingInFileNames": true,
14 |
15 | "esModuleInterop": true,
16 | "allowSyntheticDefaultImports": true
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/use-async-queue.test.jsx:
--------------------------------------------------------------------------------
1 | import "@testing-library/jest-dom";
2 | import { renderHook, waitFor, render, screen } from "@testing-library/react";
3 | import { StrictMode } from "react";
4 | import { act } from "react";
5 | import useAsyncQueue from "./dist/use-async-queue";
6 | import { Component } from "./component";
7 |
8 | describe("useAsyncQueue", () => {
9 | describe("real timers", () => {
10 | it("should initialize it", () => {
11 | const { result } = renderHook(() => useAsyncQueue({ concurrency: 1 }), {
12 | wrapper: StrictMode,
13 | });
14 | expect(typeof result.current.add).toBe("function");
15 | });
16 |
17 | it("should not add a task with the same id as an existing task", async () => {
18 | const done = jest.fn();
19 | const makeTask = (id) => {
20 | return {
21 | id,
22 | task: () => {
23 | return Promise.resolve(`${id} is done`);
24 | },
25 | };
26 | };
27 | const { result } = renderHook(
28 | () => useAsyncQueue({ concurrency: 1, done }),
29 | { wrapper: StrictMode }
30 | );
31 |
32 | expect(done).not.toHaveBeenCalled();
33 | act(() => {
34 | result.current.add(makeTask(0));
35 | });
36 | act(() => {
37 | result.current.add(makeTask(0));
38 | });
39 | await waitFor(() => {
40 | expect(done).toHaveBeenCalledTimes(1);
41 | expect(done.mock.calls[0][0].result).resolves.toBe("0 is done");
42 | });
43 | });
44 |
45 | it("should run one immediate task", async () => {
46 | const inflight = jest.fn();
47 | const done = jest.fn();
48 | const task = {
49 | id: 0,
50 | task: () => {
51 | return Promise.resolve("0 is done");
52 | },
53 | };
54 | const { result } = renderHook(
55 | () => useAsyncQueue({ concurrency: 1, inflight, done }),
56 | { wrapper: StrictMode }
57 | );
58 |
59 | expect(done).not.toHaveBeenCalled();
60 | expect(result.current.stats.numInFlight).toBe(0);
61 | expect(result.current.stats.numPending).toBe(0);
62 | expect(result.current.stats.numDone).toBe(0);
63 | act(() => result.current.add(task));
64 |
65 | await waitFor(() => {
66 | expect(inflight).toHaveBeenCalledTimes(1);
67 | expect(inflight.mock.calls[0][0]).toMatchObject({
68 | id: 0,
69 | task: expect.any(Function),
70 | });
71 | expect(done).toHaveBeenCalledTimes(1);
72 | expect(done.mock.calls[0][0]).toMatchObject({
73 | id: 0,
74 | task: expect.any(Function),
75 | result: expect.any(Promise),
76 | });
77 | expect(done.mock.calls[0][0].result).resolves.toBe("0 is done");
78 | expect(result.current.stats.numInFlight).toBe(0);
79 | expect(result.current.stats.numPending).toBe(0);
80 | expect(result.current.stats.numDone).toBe(1);
81 | });
82 | });
83 |
84 | it("should run two immediate tasks, both resolve", async () => {
85 | const done = jest.fn();
86 | const makeTask = (id) => {
87 | return {
88 | id,
89 | task: () => {
90 | return Promise.resolve(`${id} is done`);
91 | },
92 | };
93 | };
94 | const { result } = renderHook(
95 | () => useAsyncQueue({ concurrency: 1, done }),
96 | { wrapper: StrictMode }
97 | );
98 |
99 | expect(done).not.toHaveBeenCalled();
100 | await act(async () => {
101 | result.current.add(makeTask(0));
102 | });
103 | await act(async () => {
104 | result.current.add(makeTask(1));
105 | expect(done).toHaveBeenCalledTimes(1);
106 | expect(done.mock.calls[0][0].result).resolves.toBe("0 is done");
107 | });
108 | await waitFor(() => {
109 | expect(done).toHaveBeenCalledTimes(2);
110 | expect(done.mock.calls[1][0].result).resolves.toBe("1 is done");
111 | });
112 | });
113 |
114 | it("should run two immediate tasks, one resolves, one rejects", async () => {
115 | const done = jest.fn();
116 | const drain = jest.fn();
117 | const makeTask = (id) => {
118 | return {
119 | id,
120 | task: () => {
121 | if (id === 0) {
122 | return Promise.resolve(`${id} is done`);
123 | } else {
124 | return Promise.reject(`${id} rejected`);
125 | }
126 | },
127 | };
128 | };
129 | const { result } = renderHook(
130 | ({ concurrency, done, drain }) =>
131 | useAsyncQueue({ concurrency, done, drain }),
132 | { initialProps: { concurrency: 1, done, drain }, wrapper: StrictMode }
133 | );
134 |
135 | // TODO: separate drain testing into its own test case.
136 | expect(done).not.toHaveBeenCalled();
137 | expect(drain).not.toHaveBeenCalled();
138 | await act(async () => {
139 | result.current.add(makeTask(0));
140 | });
141 | await act(async () => {
142 | expect(done).toHaveBeenCalledTimes(1);
143 | expect(drain).toHaveBeenCalledTimes(1);
144 | expect(done.mock.calls[0][0].result).resolves.toBe("0 is done");
145 | result.current.add(makeTask(1));
146 | });
147 |
148 | await waitFor(() => {
149 | expect(done).toHaveBeenCalledTimes(2);
150 | expect(done.mock.calls[1][0].result).rejects.toBe("1 rejected");
151 | expect(drain).toHaveBeenCalledTimes(2);
152 | });
153 | });
154 |
155 | // This test uses a real timeout, but the call to useFakeTimers messes with
156 | // it.
157 | it.skip("should run one deferred task", async () => {
158 | const done = jest.fn();
159 | const makeTask = (id) => {
160 | return {
161 | id,
162 | task: () => {
163 | return new Promise((resolve) =>
164 | setTimeout(() => resolve(`${id} is done`), 1000 * (id + 1))
165 | );
166 | },
167 | };
168 | };
169 | const { result, waitForNextUpdate } = renderHook(
170 | () => useAsyncQueue({ concurrency: 1, done }),
171 | { wrapper: StrictMode }
172 | );
173 |
174 | expect(done).not.toHaveBeenCalled();
175 | act(() => result.current.add(makeTask(0)));
176 | await waitForNextUpdate();
177 | expect(done).toHaveBeenCalledTimes(1);
178 | expect(done.mock.calls[0][0]).toMatchObject({
179 | id: 0,
180 | task: expect.any(Function),
181 | result: expect.any(Promise),
182 | });
183 | expect(done.mock.calls[0][0].result).resolves.toBe("0 is done");
184 | });
185 | });
186 |
187 | describe("fake timers", () => {
188 | beforeEach(() => {
189 | jest.useFakeTimers();
190 | });
191 |
192 | it("should run one deferred task at a time with concurrency 1", async () => {
193 | const inflight = jest.fn();
194 | const done = jest.fn();
195 | const drain = jest.fn();
196 | const makeTask = (id) => {
197 | return {
198 | id,
199 | task: () => {
200 | return new Promise((resolve) =>
201 | setTimeout(() => {
202 | resolve(`${id} is done`);
203 | }, 1000)
204 | );
205 | },
206 | };
207 | };
208 | const { result } = renderHook(
209 | () => useAsyncQueue({ concurrency: 1, inflight, done, drain }),
210 | { wrapper: StrictMode }
211 | );
212 |
213 | expect(done).not.toHaveBeenCalled();
214 | expect(inflight).not.toHaveBeenCalled();
215 | expect(drain).not.toHaveBeenCalled();
216 | expect(result.current.stats.numInFlight).toBe(0);
217 | expect(result.current.stats.numPending).toBe(0);
218 | expect(result.current.stats.numDone).toBe(0);
219 |
220 | act(() => result.current.add(makeTask(0)));
221 | act(() => result.current.add(makeTask(1)));
222 | expect(result.current.stats.numPending).toBe(1);
223 | expect(result.current.stats.numInFlight).toBe(1);
224 | expect(inflight).toHaveBeenCalledTimes(1);
225 | jest.advanceTimersByTime(900);
226 | expect(done).not.toHaveBeenCalled();
227 |
228 | await act(async () => {
229 | expect(result.current.stats.numInFlight).toBe(1);
230 | expect(result.current.stats.numPending).toBe(1);
231 | expect(result.current.stats.numDone).toBe(0);
232 | jest.advanceTimersByTime(100);
233 | });
234 |
235 | await act(async () => {
236 | expect(done).toHaveBeenCalledTimes(1);
237 | expect(inflight).toHaveBeenCalledTimes(2);
238 | expect(drain).not.toHaveBeenCalled();
239 | expect(result.current.stats.numInFlight).toBe(1);
240 | expect(result.current.stats.numPending).toBe(0);
241 | expect(result.current.stats.numDone).toBe(1);
242 | jest.advanceTimersByTime(900);
243 | expect(done).toHaveBeenCalledTimes(1);
244 | jest.advanceTimersByTime(100);
245 | });
246 |
247 | await waitFor(() => {
248 | expect(done).toHaveBeenCalledTimes(2);
249 | expect(result.current.stats.numInFlight).toBe(0);
250 | expect(result.current.stats.numPending).toBe(0);
251 | expect(result.current.stats.numDone).toBe(2);
252 | expect(drain).toHaveBeenCalledTimes(1);
253 | });
254 | });
255 |
256 | it("should run two deferred tasks at a time with concurrency 2", async () => {
257 | const inflight = jest.fn();
258 | const done = jest.fn();
259 | const makeTask = (id) => {
260 | return {
261 | id,
262 | task: () => {
263 | return new Promise((resolve) =>
264 | setTimeout(() => {
265 | resolve(`${id} is done`);
266 | }, 1000)
267 | );
268 | },
269 | };
270 | };
271 | const { result } = renderHook(
272 | () => useAsyncQueue({ concurrency: 2, inflight, done }),
273 | { wrapper: StrictMode }
274 | );
275 |
276 | expect(done).not.toHaveBeenCalled();
277 |
278 | act(() => result.current.add(makeTask(0)));
279 | act(() => result.current.add(makeTask(1)));
280 | expect(result.current.stats.numPending).toBe(0);
281 | expect(result.current.stats.numInFlight).toBe(2);
282 | expect(inflight).toHaveBeenCalledTimes(2);
283 | expect(done).toHaveBeenCalledTimes(0);
284 |
285 | jest.advanceTimersByTime(900);
286 | expect(done).not.toHaveBeenCalled();
287 | expect(result.current.stats.numInFlight).toBe(2);
288 | expect(result.current.stats.numPending).toBe(0);
289 | expect(result.current.stats.numDone).toBe(0);
290 |
291 | jest.advanceTimersByTime(100);
292 | await waitFor(() => {
293 | expect(done).toHaveBeenCalledTimes(2);
294 | expect(result.current.stats.numInFlight).toBe(0);
295 | expect(result.current.stats.numPending).toBe(0);
296 | expect(result.current.stats.numDone).toBe(2);
297 | });
298 | });
299 | });
300 |
301 | describe("on mount", () => {
302 | it("should execute each task once", async () => {
303 | render(
304 | ,
311 | {
312 | wrapper: StrictMode,
313 | }
314 | );
315 |
316 | expect(screen.getByText("total: 3"));
317 | await waitFor(() => {
318 | expect(screen.queryAllByText(/item done/)).toHaveLength(1);
319 | });
320 | await waitFor(() => {
321 | expect(screen.queryAllByText(/item done/)).toHaveLength(2);
322 | });
323 | await waitFor(() => {
324 | expect(screen.queryAllByText(/item done/)).toHaveLength(3);
325 | });
326 | expect(screen.queryAllByText(/item done/)).not.toHaveLength(4);
327 | expect(screen.getByText("total: 3"));
328 | });
329 | });
330 | });
331 |
--------------------------------------------------------------------------------
/use-async-queue.ts:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useCallback, useEffect } from "react";
2 | import nextTick from "next-tick";
3 |
4 | interface QueueStats {
5 | numPending: number;
6 | numInFlight: number;
7 | numDone: number;
8 | }
9 |
10 | export interface QueueTaskResult {
11 | id: unknown;
12 | task(): Promise;
13 | result?: Promise;
14 | stats?: QueueStats;
15 | }
16 |
17 | interface Queue {
18 | add: (task: QueueTaskResult) => void;
19 | stats: QueueStats;
20 | }
21 |
22 | interface QueueOpts {
23 | concurrency?: number;
24 | done?: (result: QueueTaskResult) => void;
25 | drain?: () => void;
26 | inflight?: (task: QueueTaskResult) => void;
27 | }
28 |
29 | function useAsyncQueue(opts: QueueOpts): Queue {
30 | const { done, drain, inflight } = opts;
31 | let { concurrency } = opts;
32 | concurrency = concurrency || Infinity;
33 | if (concurrency < 1) concurrency = Infinity;
34 |
35 | const [stats, setStats] = useState({
36 | numPending: 0,
37 | numInFlight: 0,
38 | numDone: 0,
39 | });
40 |
41 | const drained = useRef(true);
42 | const inFlight = useRef([] as QueueTaskResult[]);
43 | const pending = useRef([] as QueueTaskResult[]);
44 |
45 | useEffect(() => {
46 | if (
47 | stats.numDone > 0 &&
48 | drain &&
49 | inFlight.current.length === 0 &&
50 | pending.current.length === 0 &&
51 | !drained.current
52 | ) {
53 | drained.current = true;
54 | return nextTick(drain);
55 | }
56 |
57 | while (
58 | inFlight.current.length < concurrency! &&
59 | pending.current.length > 0
60 | ) {
61 | drained.current = false;
62 | const task = pending.current.shift();
63 | if (task) {
64 | inFlight.current.push(task);
65 | setStats((stats) => {
66 | return {
67 | ...stats,
68 | numPending: stats.numPending - 1,
69 | numInFlight: stats.numInFlight + 1,
70 | };
71 | });
72 | inflight && inflight({ ...task, stats });
73 | const result = task.task();
74 | result
75 | .then(() => {
76 | inFlight.current.pop();
77 | setStats((stats) => {
78 | return {
79 | ...stats,
80 | numInFlight: stats.numInFlight - 1,
81 | numDone: stats.numDone + 1,
82 | };
83 | });
84 | done && done({ ...task, result, stats });
85 | })
86 | .catch(() => {
87 | inFlight.current.pop();
88 | setStats((stats) => {
89 | return {
90 | ...stats,
91 | numInFlight: stats.numInFlight - 1,
92 | numDone: stats.numDone + 1,
93 | };
94 | });
95 | done && done({ ...task, result, stats });
96 | });
97 | }
98 | }
99 | }, [concurrency, done, drain, inflight, stats]);
100 |
101 | const add = useCallback((task: QueueTaskResult) => {
102 | if (
103 | !pending.current.find((t) => {
104 | return t.id === task.id;
105 | }) &&
106 | !inFlight.current.find((t) => {
107 | return t.id === task.id;
108 | })
109 | ) {
110 | pending.current.push(task);
111 | setStats((stats) => {
112 | return {
113 | ...stats,
114 | numPending: stats.numPending + 1,
115 | };
116 | });
117 | }
118 | }, []);
119 |
120 | return { add, stats };
121 | }
122 |
123 | export default useAsyncQueue;
124 |
--------------------------------------------------------------------------------