├── .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 | --------------------------------------------------------------------------------