├── .babelrc ├── src ├── index.js ├── queues.js ├── timers.js ├── Queue.js └── scheduling.js ├── scripts ├── buildToc.js ├── testBuilds.js ├── test.js └── build.js ├── .circleci └── config.yml ├── .gitignore ├── LICENSE.md ├── tests ├── utils.js ├── priority.test.js ├── env.test.js └── Queue.test.js ├── package.json └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2016", "es2017", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as Queue } from './Queue' 2 | export { priorities } from './queues' 3 | -------------------------------------------------------------------------------- /src/queues.js: -------------------------------------------------------------------------------- 1 | export const priorities = { 2 | SYNC: 0, 3 | CRITICAL: 1, 4 | HIGH: 2, 5 | LOW: 3 6 | } 7 | 8 | export const queues = { 9 | [priorities.SYNC]: [], 10 | [priorities.CRITICAL]: [], 11 | [priorities.HIGH]: [], 12 | [priorities.LOW]: [] 13 | } 14 | -------------------------------------------------------------------------------- /scripts/buildToc.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const toc = require('markdown-toc') 4 | 5 | const readmePath = path.resolve('README.md') 6 | const oldReadme = fs.readFileSync(readmePath, 'utf8') 7 | const newReadme = toc.insert(oldReadme, { maxdepth: 3, bullets: ['*', '+'] }) 8 | 9 | fs.writeFileSync(readmePath, newReadme) 10 | -------------------------------------------------------------------------------- /scripts/testBuilds.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const { exec } = require('child_process') 4 | 5 | const distPath = path.resolve('dist') 6 | const files = fs.readdirSync(distPath) 7 | 8 | async function testBuilds () { 9 | for (let file of files) { 10 | const err = await execPromise(`BUNDLE=${file} npm run test`) 11 | if (err) { 12 | console.error('\x1b[31m', `Error in ${file}`, '\x1b[30m') 13 | } else { 14 | console.log(`${file} works as expected`) 15 | } 16 | } 17 | } 18 | 19 | function execPromise (cmd) { 20 | return new Promise(resolve => exec(cmd, resolve)) 21 | } 22 | 23 | testBuilds() 24 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: geekykaran/headless-chrome-node-docker 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | 16 | steps: 17 | - checkout 18 | - run: npm install 19 | - run: npm run lint 20 | - run: npm test 21 | - run: npm run coveralls 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Build 34 | dist 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # Local optimization utils 40 | opt 41 | 42 | # reify cache for allowing import export in node 43 | .reify-cache 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 RisingStack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/utils.js: -------------------------------------------------------------------------------- 1 | const HEAVY_INTERVAL = 10 2 | const originalRAF = window.requestAnimationFrame 3 | const originalRIC = window.requestIdleCallback 4 | 5 | export function spy (fn) { 6 | const spyFn = () => { 7 | fn() 8 | spyFn.callCount++ 9 | } 10 | spyFn.callCount = 0 11 | return spyFn 12 | } 13 | 14 | export function beforeNextFrame () { 15 | const nextFrame = 16 | typeof requestAnimationFrame === 'function' 17 | ? requestAnimationFrame 18 | : setTimeout 19 | return new Promise(nextFrame) 20 | } 21 | 22 | export function heavyCalculation () { 23 | const start = Date.now() 24 | const parent = document.createElement('div') 25 | while (Date.now() - start < HEAVY_INTERVAL) { 26 | const child = document.createElement('div') 27 | parent.appendChild(child) 28 | parent.removeChild(child) 29 | } 30 | return Date.now() - start 31 | } 32 | 33 | export function removeRAF () { 34 | window.requestAnimationFrame = undefined 35 | } 36 | 37 | export function restoreRAF () { 38 | window.requestAnimationFrame = originalRAF 39 | } 40 | 41 | export function removeRIC () { 42 | window.requestIdleCallback = undefined 43 | } 44 | 45 | export function restoreRIC () { 46 | window.requestIdleCallback = originalRIC 47 | } 48 | -------------------------------------------------------------------------------- /scripts/test.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const resolve = require('rollup-plugin-node-resolve') 3 | const commonjs = require('rollup-plugin-commonjs') 4 | const babel = require('rollup-plugin-babel') 5 | const coverage = require('rollup-plugin-coverage') 6 | const alias = require('rollup-plugin-alias') 7 | const TestServer = require('karma').Server 8 | 9 | const bundleName = process.env.BUNDLE 10 | const bundlePath = bundleName ? `dist/${bundleName}` : 'src/index.js' 11 | 12 | const config = { 13 | frameworks: ['mocha', 'chai', 'source-map-support'], 14 | reporters: ['mocha', 'coverage'], 15 | files: ['tests/**/*.test.js'], 16 | preprocessors: { 17 | 'tests/**/*.test.js': ['rollup'] 18 | }, 19 | rollupPreprocessor: { 20 | plugins: [ 21 | babel({ 22 | exclude: 'node_modules/**' 23 | }), 24 | resolve(), 25 | commonjs({ 26 | namedExports: { 27 | 'node_modules/chai/index.js': ['expect'] 28 | } 29 | }), 30 | alias({ 31 | '@nx-js/queue-util': path.resolve(bundlePath) 32 | }), 33 | coverage({ 34 | include: ['src/**/*.js'] 35 | }) 36 | ], 37 | format: 'iife', 38 | name: 'queue', 39 | sourcemap: 'inline' 40 | }, 41 | coverageReporter: { 42 | dir: 'coverage', 43 | reporters: [{ type: 'lcov', subdir: '.' }, { type: 'text-summary' }] 44 | }, 45 | port: 9876, 46 | colors: true, 47 | autoWatch: false, 48 | concurrency: Infinity, 49 | singleRun: true, 50 | browsers: ['ChromeHeadlessNoSandbox'], 51 | customLaunchers: { 52 | ChromeHeadlessNoSandbox: { 53 | base: 'ChromeHeadless', 54 | flags: ['--no-sandbox'] 55 | } 56 | } 57 | } 58 | 59 | const testServer = new TestServer(config, exitCode => { 60 | console.log(`Karma has exited with ${exitCode}`) 61 | process.exit(exitCode) 62 | }) 63 | testServer.start() 64 | -------------------------------------------------------------------------------- /src/timers.js: -------------------------------------------------------------------------------- 1 | let tickTask // tick means the next microtask 2 | let rafTask // raf means the next animation frame (requestAnimationFrame) 3 | let ricTask // ric means the next idle perdiod (requestIdleCallback) 4 | 5 | const currentTick = Promise.resolve() 6 | 7 | function getRaf () { 8 | return typeof requestAnimationFrame === 'function' 9 | ? requestAnimationFrame 10 | : setTimeout 11 | } 12 | 13 | function getRic () { 14 | return typeof requestIdleCallback === 'function' 15 | ? requestIdleCallback 16 | : getRaf() 17 | } 18 | 19 | // schedule a tick task, if it is not yet scheduled 20 | export function nextTick (task) { 21 | if (!tickTask) { 22 | tickTask = task 23 | currentTick.then(runTickTask) 24 | } 25 | } 26 | 27 | function runTickTask () { 28 | const task = tickTask 29 | // set the task to undefined BEFORE calling it 30 | // this allows it to re-schedule itself for a later time 31 | tickTask = undefined 32 | task() 33 | } 34 | 35 | // schedule a raf task, if it is not yet scheduled 36 | export function nextAnimationFrame (task) { 37 | if (!rafTask) { 38 | rafTask = task 39 | const raf = getRaf() 40 | raf(runRafTask) 41 | } 42 | } 43 | 44 | function runRafTask () { 45 | const task = rafTask 46 | // set the task to undefined BEFORE calling it 47 | // this allows it to re-schedule itself for a later time 48 | rafTask = undefined 49 | task() 50 | } 51 | 52 | // schedule a ric task, if it is not yet scheduled 53 | export function nextIdlePeriod (task) { 54 | if (!ricTask) { 55 | ricTask = task 56 | const ric = getRic() 57 | ric(runRicTask) 58 | } 59 | } 60 | 61 | function runRicTask () { 62 | // do not run ric task if there are pending raf tasks 63 | // let the raf tasks execute first and schedule the ric task for later 64 | if (!rafTask) { 65 | const task = ricTask 66 | // set the task to undefined BEFORE calling it 67 | // this allows it to re-schedule itself for a later time 68 | ricTask = undefined 69 | task() 70 | } else { 71 | const ric = getRic() 72 | ric(runRicTask) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Queue.js: -------------------------------------------------------------------------------- 1 | import { queues, priorities } from './queues' 2 | import { queueTaskProcessing, runTask } from './scheduling' 3 | 4 | const QUEUE = Symbol('task queue') 5 | const IS_STOPPED = Symbol('is stopped') 6 | const IS_SLEEPING = Symbol('is sleeping') 7 | 8 | export default class Queue { 9 | constructor (priority = priorities.SYNC) { 10 | this[QUEUE] = new Set() 11 | this.priority = priority 12 | queues[this.priority].push(this[QUEUE]) 13 | } 14 | 15 | has (task) { 16 | return this[QUEUE].has(task) 17 | } 18 | 19 | add (task) { 20 | if (this[IS_SLEEPING]) { 21 | return 22 | } 23 | if (this.priority === priorities.SYNC && !this[IS_STOPPED]) { 24 | task() 25 | } else { 26 | const queue = this[QUEUE] 27 | queue.add(task) 28 | } 29 | if (!this[IS_STOPPED]) { 30 | queueTaskProcessing(this.priority) 31 | } 32 | } 33 | 34 | delete (task) { 35 | this[QUEUE].delete(task) 36 | } 37 | 38 | start () { 39 | const queue = this[QUEUE] 40 | if (this.priority === priorities.SYNC) { 41 | this.process() 42 | } else { 43 | const priorityQueues = queues[this.priority] 44 | if (priorityQueues.indexOf(queue) === -1) { 45 | priorityQueues.push(queue) 46 | } 47 | queueTaskProcessing(this.priority) 48 | } 49 | this[IS_STOPPED] = false 50 | this[IS_SLEEPING] = false 51 | } 52 | 53 | stop () { 54 | const queue = this[QUEUE] 55 | const priorityQueues = queues[this.priority] 56 | const index = priorityQueues.indexOf(queue) 57 | if (index !== -1) { 58 | priorityQueues.splice(index, 1) 59 | } 60 | this[IS_STOPPED] = true 61 | } 62 | 63 | sleep () { 64 | this.stop() 65 | this[IS_SLEEPING] = true 66 | } 67 | 68 | clear () { 69 | this[QUEUE].clear() 70 | } 71 | 72 | get size () { 73 | return this[QUEUE].size 74 | } 75 | 76 | process () { 77 | const queue = this[QUEUE] 78 | queue.forEach(runTask) 79 | queue.clear() 80 | } 81 | 82 | processing () { 83 | const queue = this[QUEUE] 84 | return new Promise(resolve => { 85 | if (queue.size === 0) { 86 | resolve() 87 | } else { 88 | queue.add(resolve) 89 | } 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/scheduling.js: -------------------------------------------------------------------------------- 1 | import { nextTick, nextAnimationFrame, nextIdlePeriod } from './timers' 2 | import { queues, priorities } from './queues' 3 | 4 | const TARGET_FPS = 60 5 | const TARGET_INTERVAL = 1000 / TARGET_FPS 6 | 7 | export function queueTaskProcessing (priority) { 8 | if (priority === priorities.CRITICAL) { 9 | nextTick(runQueuedCriticalTasks) 10 | } else if (priority === priorities.HIGH) { 11 | nextAnimationFrame(runQueuedHighTasks) 12 | } else if (priority === priorities.LOW) { 13 | nextIdlePeriod(runQueuedLowTasks) 14 | } 15 | } 16 | 17 | function runQueuedCriticalTasks () { 18 | // critical tasks must all execute before the next frame 19 | const criticalQueues = queues[priorities.CRITICAL] 20 | criticalQueues.forEach(processCriticalQueue) 21 | } 22 | 23 | function processCriticalQueue (queue) { 24 | queue.forEach(runTask) 25 | queue.clear() 26 | } 27 | 28 | function runQueuedHighTasks () { 29 | const startTime = Date.now() 30 | const isEmpty = processIdleQueues(priorities.HIGH, startTime) 31 | // there are more tasks to run in the next cycle 32 | if (!isEmpty) { 33 | nextAnimationFrame(runQueuedHighTasks) 34 | } 35 | } 36 | 37 | function runQueuedLowTasks () { 38 | const startTime = Date.now() 39 | const isEmpty = processIdleQueues(priorities.LOW, startTime) 40 | // there are more tasks to run in the next cycle 41 | if (!isEmpty) { 42 | nextIdlePeriod(runQueuedLowTasks) 43 | } 44 | } 45 | 46 | function processIdleQueues (priority, startTime) { 47 | const idleQueues = queues[priority] 48 | let isEmpty = true 49 | 50 | // if a queue is not empty after processing, it means we have no more time 51 | // the loop whould stop in this case 52 | for (let i = 0; isEmpty && i < idleQueues.length; i++) { 53 | const queue = idleQueues.shift() 54 | isEmpty = isEmpty && processIdleQueue(queue, startTime) 55 | idleQueues.push(queue) 56 | } 57 | return isEmpty 58 | } 59 | 60 | function processIdleQueue (queue, startTime) { 61 | const iterator = queue[Symbol.iterator]() 62 | let task = iterator.next() 63 | while (Date.now() - startTime < TARGET_INTERVAL) { 64 | if (task.done) { 65 | return true 66 | } 67 | runTask(task.value) 68 | queue.delete(task.value) 69 | task = iterator.next() 70 | } 71 | } 72 | 73 | export function runTask (task) { 74 | task() 75 | } 76 | -------------------------------------------------------------------------------- /tests/priority.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { beforeNextFrame, heavyCalculation } from './utils' 3 | import { Queue, priorities } from '@nx-js/queue-util' 4 | 5 | describe('priorities and processing', () => { 6 | it('should run all critical tasks before the next frame', async () => { 7 | let runs = 0 8 | const queue = new Queue(priorities.CRITICAL) 9 | for (let i = 0; i < 10; i++) { 10 | queue.add(() => { 11 | heavyCalculation() 12 | runs++ 13 | }) 14 | } 15 | 16 | expect(queue.size).to.equal(10) 17 | await beforeNextFrame() 18 | expect(runs).to.equal(10) 19 | expect(queue.size).to.equal(0) 20 | }) 21 | 22 | it('should run all critical tasks before high prio tasks before the low prio tasks', async () => { 23 | let criticalRuns = 0 24 | let highRuns = 0 25 | let lowRuns = 0 26 | 27 | const criticalQueue = new Queue(priorities.CRITICAL) 28 | const highQueue = new Queue(priorities.HIGH) 29 | const lowQueue = new Queue(priorities.LOW) 30 | 31 | for (let i = 0; i < 10; i++) { 32 | criticalQueue.add(() => { 33 | criticalRuns++ 34 | heavyCalculation() 35 | }) 36 | 37 | highQueue.add(() => { 38 | highRuns++ 39 | heavyCalculation() 40 | }) 41 | 42 | lowQueue.add(() => { 43 | lowRuns++ 44 | heavyCalculation() 45 | }) 46 | } 47 | 48 | await criticalQueue.processing() 49 | expect(criticalRuns).to.equal(10) 50 | expect(highRuns).to.equal(0) 51 | expect(lowRuns).to.equal(0) 52 | await highQueue.processing() 53 | expect(criticalRuns).to.equal(10) 54 | expect(highRuns).to.equal(10) 55 | expect(lowRuns).to.equal(0) 56 | await lowQueue.processing() 57 | expect(criticalRuns).to.equal(10) 58 | expect(highRuns).to.equal(10) 59 | expect(lowRuns).to.equal(10) 60 | }) 61 | 62 | it('should process non critical tasks in chunks to achieve 60 fps', async () => { 63 | const lowQueue = new Queue(priorities.LOW) 64 | const highQueue = new Queue(priorities.HIGH) 65 | 66 | for (let i = 0; i < 10; i++) { 67 | highQueue.add(() => heavyCalculation()) 68 | lowQueue.add(() => heavyCalculation()) 69 | } 70 | 71 | await beforeNextFrame() 72 | expect(highQueue.size).to.not.eql(0) 73 | expect(lowQueue.size).to.eql(10) 74 | await lowQueue.processing() 75 | expect(highQueue.size).to.eql(0) 76 | expect(lowQueue.size).to.eql(0) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nx-js/queue-util", 3 | "version": "1.1.1", 4 | "description": "An NX queue utility for splitting up heavy work.", 5 | "main": "dist/cjs.es5.js", 6 | "module": "dist/es.es5.js", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "test": "node ./scripts/test.js", 12 | "test-builds": "node ./scripts/testBuilds.js", 13 | "lint": "standard", 14 | "lint-fix": "prettier --ignore-path '.gitignore' --write '**/!(bundle).js' && standard --fix", 15 | "coveralls": "cat ./coverage/lcov.info | ./node_modules/.bin/coveralls", 16 | "build": "node ./scripts/build.js", 17 | "build-toc": "node ./scripts/buildToc.js" 18 | }, 19 | "author": { 20 | "name": "Miklos Bertalan", 21 | "email": "miklos.bertalan@risingstack.com" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git@github.com:nx-js/queue-util.git" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/nx-js/queue-util/issues" 29 | }, 30 | "homepage": "https://github.com/nx-js/queue-util#readme", 31 | "license": "MIT", 32 | "keywords": [ 33 | "nx", 34 | "queue", 35 | "task", 36 | "60fps" 37 | ], 38 | "devDependencies": { 39 | "babel-core": "6.25.0", 40 | "babel-minify": "^0.2.0", 41 | "babel-preset-es2016": "^6.24.1", 42 | "babel-preset-es2017": "^6.24.1", 43 | "babel-preset-react": "^6.24.1", 44 | "babel-preset-stage-0": "^6.24.1", 45 | "buble": "^0.15.2", 46 | "chai": "^4.1.1", 47 | "coveralls": "^2.13.1", 48 | "dirty-chai": "^2.0.1", 49 | "karma": "^1.7.0", 50 | "karma-chai": "^0.1.0", 51 | "karma-chrome-launcher": "^2.2.0", 52 | "karma-coverage": "^1.1.1", 53 | "karma-mocha": "^1.3.0", 54 | "karma-mocha-reporter": "^2.2.5", 55 | "karma-rollup-preprocessor": "^5.0.1", 56 | "karma-source-map-support": "^1.2.0", 57 | "markdown-toc": "^1.1.0", 58 | "mocha": "^3.5.0", 59 | "nyc": "11.1.0", 60 | "pre-push": "^0.1.1", 61 | "prettier": "^1.6.1", 62 | "rollup": "^0.49.0", 63 | "rollup-plugin-alias": "^1.3.1", 64 | "rollup-plugin-auto-external": "^1.0.0", 65 | "rollup-plugin-babel": "^3.0.2", 66 | "rollup-plugin-commonjs": "^8.2.0", 67 | "rollup-plugin-coverage": "^0.1.4", 68 | "rollup-plugin-node-resolve": "^3.0.0", 69 | "standard": "^10.0.3" 70 | }, 71 | "engines": { 72 | "node": ">=6.5.0" 73 | }, 74 | "standard": { 75 | "env": [ 76 | "browser", 77 | "mocha" 78 | ] 79 | }, 80 | "pre-push": [ 81 | "lint", 82 | "test" 83 | ] 84 | } 85 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const del = require('del') 4 | const babel = require('babel-core') 5 | const buble = require('buble') 6 | const rollup = require('rollup') 7 | const resolvePlugin = require('rollup-plugin-node-resolve') 8 | const babelPlugin = require('rollup-plugin-babel') 9 | const externalsPlugin = require('rollup-plugin-auto-external') 10 | 11 | const bundles = [ 12 | { 13 | input: { 14 | input: path.resolve('src/index.js'), 15 | plugins: [ 16 | babelPlugin({ 17 | exclude: 'node_modules/**' 18 | }), 19 | resolvePlugin(), 20 | externalsPlugin({ dependencies: true, peerDependecies: true }) 21 | ] 22 | }, 23 | output: { 24 | format: 'es' 25 | } 26 | }, 27 | { 28 | input: { 29 | input: path.resolve('src/index.js'), 30 | plugins: [ 31 | babelPlugin({ 32 | exclude: 'node_modules/**' 33 | }), 34 | resolvePlugin(), 35 | externalsPlugin({ dependencies: true, peerDependecies: true }) 36 | ] 37 | }, 38 | output: { 39 | format: 'cjs' 40 | } 41 | }, 42 | { 43 | input: { 44 | input: path.resolve('src/index.js'), 45 | plugins: [ 46 | babelPlugin({ 47 | exclude: 'node_modules/**' 48 | }), 49 | resolvePlugin(), 50 | externalsPlugin({ dependencies: false, peerDependecies: true }) 51 | ] 52 | }, 53 | output: { 54 | format: 'umd', 55 | name: 'queue' 56 | } 57 | } 58 | ] 59 | 60 | async function build () { 61 | // Clean up the output directory 62 | await del(path.resolve('dist')) 63 | fs.mkdirSync(path.resolve('dist')) 64 | 65 | // Compile source code into a distributable format with Babel and Rollup 66 | for (const config of bundles) { 67 | const es6Path = path.resolve('dist', `${config.output.format}.es6.js`) 68 | const bundle = await rollup.rollup(config.input) 69 | const { code: es6Code } = await bundle.generate(config.output) 70 | fs.writeFileSync(es6Path, es6Code, 'utf-8') 71 | 72 | const es6MinPath = path.resolve( 73 | 'dist', 74 | `${config.output.format}.es6.min.js` 75 | ) 76 | const { code: es6MinCode } = babel.transform(es6Code, { 77 | presets: ['minify'] 78 | }) 79 | fs.writeFileSync(es6MinPath, es6MinCode, 'utf-8') 80 | 81 | const es5Path = path.resolve('dist', `${config.output.format}.es5.js`) 82 | const { code: es5Code } = buble.transform(es6Code, { 83 | transforms: { 84 | dangerousForOf: true, 85 | modules: false 86 | } 87 | }) 88 | fs.writeFileSync(es5Path, es5Code, 'utf-8') 89 | 90 | const es5MinPath = path.resolve( 91 | 'dist', 92 | `${config.output.format}.es5.min.js` 93 | ) 94 | const { code: es5MinCode } = babel.transform(es5Code, { 95 | presets: ['minify'] 96 | }) 97 | fs.writeFileSync(es5MinPath, es5MinCode, 'utf-8') 98 | } 99 | } 100 | 101 | build() 102 | -------------------------------------------------------------------------------- /tests/env.test.js: -------------------------------------------------------------------------------- 1 | // remove requestAnimationFrame and requestIdleCallback before the queue schedulers load 2 | import { expect } from 'chai' 3 | import { Queue, priorities } from '@nx-js/queue-util' 4 | import { 5 | removeRIC, 6 | restoreRIC, 7 | removeRAF, 8 | restoreRAF, 9 | beforeNextFrame, 10 | heavyCalculation 11 | } from './utils' 12 | 13 | describe('environments', () => { 14 | describe('NodeJS', () => { 15 | before(() => { 16 | removeRIC() 17 | removeRAF() 18 | }) 19 | 20 | after(() => { 21 | restoreRIC() 22 | restoreRAF() 23 | }) 24 | 25 | it('should run all critical tasks before high prio tasks before low prio tasks', async () => { 26 | let criticalRuns = 0 27 | let highRuns = 0 28 | let lowRuns = 0 29 | 30 | const criticalQueue = new Queue(priorities.CRITICAL) 31 | const highQueue = new Queue(priorities.HIGH) 32 | const lowQueue = new Queue(priorities.LOW) 33 | 34 | for (let i = 0; i < 10; i++) { 35 | criticalQueue.add(() => { 36 | criticalRuns++ 37 | heavyCalculation() 38 | }) 39 | 40 | highQueue.add(() => { 41 | highRuns++ 42 | heavyCalculation() 43 | }) 44 | 45 | lowQueue.add(() => { 46 | lowRuns++ 47 | heavyCalculation() 48 | }) 49 | } 50 | 51 | await criticalQueue.processing() 52 | expect(criticalRuns).to.equal(10) 53 | expect(highRuns).to.equal(0) 54 | expect(lowRuns).to.equal(0) 55 | await highQueue.processing() 56 | expect(criticalRuns).to.equal(10) 57 | expect(highRuns).to.equal(10) 58 | expect(lowRuns).to.equal(0) 59 | await lowQueue.processing() 60 | expect(criticalRuns).to.equal(10) 61 | expect(highRuns).to.equal(10) 62 | expect(lowRuns).to.equal(10) 63 | }) 64 | 65 | it('should process non critical tasks in chunks to achieve 60 fps', async () => { 66 | const lowQueue = new Queue(priorities.LOW) 67 | const highQueue = new Queue(priorities.HIGH) 68 | 69 | for (let i = 0; i < 10; i++) { 70 | highQueue.add(() => heavyCalculation()) 71 | lowQueue.add(() => heavyCalculation()) 72 | } 73 | 74 | await beforeNextFrame() 75 | expect(highQueue.size).to.not.eql(0) 76 | expect(lowQueue.size).to.eql(10) 77 | await lowQueue.processing() 78 | expect(highQueue.size).to.eql(0) 79 | expect(lowQueue.size).to.eql(0) 80 | }) 81 | }) 82 | 83 | describe('older browsers', () => { 84 | before(() => { 85 | removeRIC() 86 | }) 87 | 88 | after(() => { 89 | restoreRIC() 90 | }) 91 | 92 | it('should run all critical tasks before high prio tasks before low prio tasks', async () => { 93 | let criticalRuns = 0 94 | let highRuns = 0 95 | let lowRuns = 0 96 | 97 | const criticalQueue = new Queue(priorities.CRITICAL) 98 | const highQueue = new Queue(priorities.HIGH) 99 | const lowQueue = new Queue(priorities.LOW) 100 | 101 | for (let i = 0; i < 10; i++) { 102 | criticalQueue.add(() => { 103 | criticalRuns++ 104 | heavyCalculation() 105 | }) 106 | 107 | highQueue.add(() => { 108 | highRuns++ 109 | heavyCalculation() 110 | }) 111 | 112 | lowQueue.add(() => { 113 | lowRuns++ 114 | heavyCalculation() 115 | }) 116 | } 117 | 118 | await criticalQueue.processing() 119 | expect(criticalRuns).to.equal(10) 120 | expect(highRuns).to.equal(0) 121 | expect(lowRuns).to.equal(0) 122 | await highQueue.processing() 123 | expect(criticalRuns).to.equal(10) 124 | expect(highRuns).to.equal(10) 125 | expect(lowRuns).to.equal(0) 126 | await lowQueue.processing() 127 | expect(criticalRuns).to.equal(10) 128 | expect(highRuns).to.equal(10) 129 | expect(lowRuns).to.equal(10) 130 | }) 131 | 132 | it('should process non critical tasks in chunks to achieve 60 fps', async () => { 133 | const lowQueue = new Queue(priorities.LOW) 134 | const highQueue = new Queue(priorities.HIGH) 135 | 136 | for (let i = 0; i < 10; i++) { 137 | highQueue.add(() => heavyCalculation()) 138 | lowQueue.add(() => heavyCalculation()) 139 | } 140 | 141 | await beforeNextFrame() 142 | expect(highQueue.size).to.not.eql(0) 143 | expect(lowQueue.size).to.eql(10) 144 | await lowQueue.processing() 145 | expect(highQueue.size).to.eql(0) 146 | expect(lowQueue.size).to.eql(0) 147 | }) 148 | }) 149 | }) 150 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Queue Utility 2 | 3 | Priority based task scheduling for splitting up heavy work :muscle: 4 | 5 | [![Build](https://img.shields.io/circleci/project/github/nx-js/queue-util/master.svg)](https://circleci.com/gh/nx-js/queue-util/tree/master) [![Coverage Status](https://coveralls.io/repos/github/nx-js/queue-util/badge.svg)](https://coveralls.io/github/nx-js/queue-util) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) [![Package size](http://img.badgesize.io/https://unpkg.com/@nx-js/queue-util/dist/umd.es6.min.js?compression=gzip&label=minzip_size)](https://unpkg.com/@nx-js/queue-util/dist/umd.es6.min.js) [![Version](https://img.shields.io/npm/v/@nx-js/queue-util.svg)](https://www.npmjs.com/package/@nx-js/queue-util) [![dependencies Status](https://david-dm.org/nx-js/queue-util/status.svg)](https://david-dm.org/nx-js/queue-util) [![License](https://img.shields.io/npm/l/@nx-js/queue-util.svg)](https://www.npmjs.com/package/@nx-js/queue-util) 6 | 7 |
8 | Table of Contents 9 | 10 | 11 | 12 | 13 | * [Motivation](#motivation) 14 | * [Installation](#installation) 15 | * [Usage](#usage) 16 | * [API](#api) 17 | + [queue = new Queue(priority)](#queue--new-queuepriority) 18 | + [priorities](#priorities) 19 | + [queue.add(fn)](#queueaddfn) 20 | + [queue.delete(fn)](#queuedeletefn) 21 | + [boolean = queue.has(fn)](#boolean--queuehasfn) 22 | + [queue.clear()](#queueclear) 23 | + [queue.process()](#queueprocess) 24 | + [queue.start()](#queuestart) 25 | + [queue.stop()](#queuestop) 26 | + [promise = queue.processing()](#promise--queueprocessing) 27 | * [Alternative builds](#alternative-builds) 28 | * [Contributing](#contributing) 29 | 30 | 31 | 32 |
33 | 34 | ## Motivation 35 | 36 | Deciding what code to execute next is not an easy decision. Users expect a lot to happen simultaneously - like networking, view updates and smooth animations. The Queue Utility automatically schedules your tasks by priorities, but also lets you control them manually - when the need arises. 37 | 38 | ## Installation 39 | 40 | ``` 41 | $ npm install @nx-js/queue-util 42 | ``` 43 | 44 | ## Usage 45 | 46 | Functions can added to queues, which execute them in an order based on their priority. Queues are created by passing a priority level to the `Queue` constructor. 47 | 48 | ```js 49 | import { Queue, priorities } from '@nx-js/queue-util' 50 | 51 | const queue = new Queue(priorities.LOW) 52 | const criticalQueue = new Queue(priorities.CRITICAL) 53 | 54 | // 'Hello World' will be logged when the process has some free time 55 | queue.add(() => console.log('Hello World')) 56 | // 'EMERGENCY!' will be logged ASAP (before 'Hello World') 57 | criticalQueue.add(() => console.log('EMERGENCY!')) 58 | ``` 59 | 60 | ## API 61 | 62 | ### queue = new Queue(priority) 63 | 64 | Queue instances can be created with the `Queue` constructor. The constructor requires a single priority as argument. 65 | 66 | ### priorities 67 | 68 | The following priorities are exported on the `priorities` object. 69 | 70 | - `priorities.SYNC`: Tasks are executed right away synchronously. 71 | - `priorities.CRITICAL`: Tasks are executed ASAP (always before the next repaint in the browser). 72 | - `priorities.HIGH`: Tasks are executed when there is free time and no more pending critical tasks. 73 | - `priorities.LOW`: Tasks are executed when there is free time and no more pending critical or high prio tasks. 74 | 75 | ### queue.add(fn) 76 | 77 | Adds the passed function as a pending task to the queue. Adding the same task multiple times to a queue will only add it once. 78 | 79 | ### queue.delete(fn) 80 | 81 | Deletes the passed function from the queue. 82 | 83 | ### boolean = queue.has(fn) 84 | 85 | Returns a boolean, which indicates if the passed function is in the queue or not. 86 | 87 | ### queue.clear() 88 | 89 | Clears every task from the queue without executing them. 90 | 91 | ### queue.process() 92 | 93 | Executes every task in the queue, then clears the queue. 94 | 95 | ### queue.stop() 96 | 97 | Stops the automatic task execution of the queue. 98 | 99 | ### queue.start() 100 | 101 | Starts the - priority based - automatic task execution of the queue. The queue is automatically started after creation. 102 | 103 | ### promise = queue.processing() 104 | 105 | Returns a promise, which resolves after all of the current tasks in the queue are executed. If the queue is empty, it resolves immediately. 106 | 107 | ## Alternative builds 108 | 109 | This library detects if you use ES6 or commonJS modules and serve the right format to you. The exposed bundles are transpiled to ES5 to support common tools - like UglifyJS minifying. If you would like a finer control over the provided build, you can specify them in your imports. 110 | 111 | - `@nx-js/queue-util/dist/es.es6.js` exposes an ES6 build with ES6 modules. 112 | - `@nx-js/queue-util/dist/es.es5.js` exposes an ES5 build with ES6 modules. 113 | - `@nx-js/queue-util/dist/cjs.es6.js` exposes an ES6 build with commonJS modules. 114 | - `@nx-js/queue-util/dist/cjs.es5.js` exposes an ES5 build with commonJS modules. 115 | 116 | If you use a bundler, set up an alias for `@nx-js/queue-util` to point to your desired build. You can learn how to do it with webpack [here](https://webpack.js.org/configuration/resolve/#resolve-alias) and with rollup [here](https://github.com/rollup/rollup-plugin-alias#usage). 117 | 118 | ## Contributing 119 | 120 | Contributions are always welcomed! Just send a PR for fixes and doc updates and open issues for new features beforehand. Make sure that the tests and the linter pass and that the coverage remains high. Thanks! 121 | -------------------------------------------------------------------------------- /tests/Queue.test.js: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai' 2 | import dirtyChai from 'dirty-chai' 3 | import { Queue, priorities } from '@nx-js/queue-util' 4 | import { spy, beforeNextFrame } from './utils' 5 | 6 | chai.use(dirtyChai) 7 | 8 | describe('Queue', () => { 9 | it('should auto run the added tasks', async () => { 10 | const queue = new Queue(priorities.CRITICAL) 11 | const taskSpy1 = spy(() => {}) 12 | const taskSpy2 = spy(() => {}) 13 | queue.add(taskSpy1) 14 | queue.add(taskSpy2) 15 | await queue.processing() 16 | expect(queue.size).to.eql(0) 17 | expect(taskSpy1.callCount).to.eql(1) 18 | expect(taskSpy2.callCount).to.eql(1) 19 | }) 20 | 21 | describe('has', () => { 22 | it('should return with a boolean indication if the task is in the queue', () => { 23 | const queue = new Queue(priorities.CRITICAL) 24 | const task = () => {} 25 | expect(queue.has(task)).to.be.false() 26 | queue.add(task) 27 | expect(queue.has(task)).to.be.true() 28 | }) 29 | }) 30 | 31 | describe('add', () => { 32 | it('should add the task to the queue', () => { 33 | const queue = new Queue(priorities.HIGH) 34 | const task = spy(() => {}) 35 | expect(queue.has(task)).to.be.false() 36 | queue.add(task) 37 | expect(queue.has(task)).to.be.true() 38 | expect(task.callCount).to.eql(0) 39 | }) 40 | 41 | it('should run the task if it has a SYNC priority', () => { 42 | const queue = new Queue(priorities.SYNC) 43 | const task = spy(() => {}) 44 | queue.add(task) 45 | expect(task.callCount).to.eql(1) 46 | queue.add(task) 47 | expect(task.callCount).to.eql(2) 48 | }) 49 | 50 | it('should ignore duplicate entries', () => { 51 | const queue = new Queue(priorities.HIGH) 52 | const task = () => {} 53 | expect(queue.has(task)).to.be.false() 54 | queue.add(task) 55 | queue.add(task) 56 | queue.add(task) 57 | expect(queue.size).to.eql(1) 58 | expect(queue.has(task)).to.be.true() 59 | }) 60 | }) 61 | 62 | describe('delete', () => { 63 | it('should delete the task from the queue', () => { 64 | const queue = new Queue(priorities.LOW) 65 | const task = () => {} 66 | queue.add(task) 67 | expect(queue.has(task)).to.be.true() 68 | queue.delete(task) 69 | expect(queue.has(task)).to.be.false() 70 | }) 71 | 72 | it('should delete async tasks from the queue', () => { 73 | const queue = new Queue(priorities.SYNC) 74 | const task = () => {} 75 | queue.stop() 76 | queue.add(task) 77 | expect(queue.has(task)).to.be.true() 78 | queue.delete(task) 79 | expect(queue.has(task)).to.be.false() 80 | }) 81 | }) 82 | 83 | describe('size', () => { 84 | it('should return the size of the queue', () => { 85 | const queue = new Queue(priorities.CRITICAL) 86 | const task = () => {} 87 | expect(queue.size).to.eql(0) 88 | queue.add(task) 89 | expect(queue.size).to.eql(1) 90 | queue.delete(task) 91 | expect(queue.size).to.eql(0) 92 | }) 93 | 94 | it('should throw on set operations', () => { 95 | const queue = new Queue(priorities.CRITICAL) 96 | expect(() => (queue.size = 12)).to.throw() 97 | }) 98 | }) 99 | 100 | describe('clear', () => { 101 | it('should clear the queue', () => { 102 | const queue = new Queue(priorities.CRITICAL) 103 | const task = () => {} 104 | queue.add(task) 105 | expect(queue.size).to.eql(1) 106 | queue.clear() 107 | expect(queue.size).to.eql(0) 108 | expect(queue.has(task)).to.be.false() 109 | }) 110 | }) 111 | 112 | describe('stop', async () => { 113 | it('should stop the automatic queue processing', async () => { 114 | const queue = new Queue(priorities.CRITICAL) 115 | const taskSpy = spy(() => {}) 116 | queue.add(taskSpy) 117 | await queue.processing() 118 | expect(queue.size).to.eql(0) 119 | expect(taskSpy.callCount).to.eql(1) 120 | queue.add(taskSpy) 121 | queue.stop() 122 | await beforeNextFrame() 123 | expect(queue.size).to.eql(1) 124 | expect(taskSpy.callCount).to.eql(1) 125 | }) 126 | 127 | it('should not start again until start is called', async () => { 128 | const queue = new Queue(priorities.CRITICAL) 129 | const taskSpy = spy(() => {}) 130 | queue.add(taskSpy) 131 | await queue.processing() 132 | expect(queue.size).to.eql(0) 133 | expect(taskSpy.callCount).to.eql(1) 134 | queue.stop() 135 | queue.add(taskSpy) 136 | await beforeNextFrame() 137 | expect(queue.size).to.eql(1) 138 | expect(taskSpy.callCount).to.eql(1) 139 | queue.start() 140 | await beforeNextFrame() 141 | expect(queue.size).to.eql(0) 142 | expect(taskSpy.callCount).to.eql(2) 143 | }) 144 | 145 | it('should have the same effect on multiple calls', async () => { 146 | const queue = new Queue(priorities.CRITICAL) 147 | const taskSpy = spy(() => {}) 148 | queue.add(taskSpy) 149 | await queue.processing() 150 | expect(queue.size).to.eql(0) 151 | expect(taskSpy.callCount).to.eql(1) 152 | queue.add(taskSpy) 153 | queue.stop() 154 | queue.stop() 155 | queue.stop() 156 | await beforeNextFrame() 157 | expect(queue.size).to.eql(1) 158 | expect(taskSpy.callCount).to.eql(1) 159 | }) 160 | 161 | it('should queue tasks instead of discarding them with SYNC priority', () => { 162 | const queue = new Queue(priorities.SYNC) 163 | const taskSpy = spy(() => {}) 164 | expect(queue.size).to.eql(0) 165 | queue.stop() 166 | queue.add(taskSpy) 167 | queue.add(taskSpy) 168 | expect(queue.size).to.eql(1) 169 | expect(taskSpy.callCount).to.eql(0) 170 | }) 171 | }) 172 | 173 | describe('start', () => { 174 | it('should start the automatic queue processing after a stop', async () => { 175 | const queue = new Queue(priorities.CRITICAL) 176 | const taskSpy = spy(() => {}) 177 | queue.add(taskSpy) 178 | queue.stop() 179 | await beforeNextFrame() 180 | expect(queue.size).to.eql(1) 181 | expect(taskSpy.callCount).to.eql(0) 182 | queue.start() 183 | await queue.processing() 184 | expect(queue.size).to.eql(0) 185 | expect(taskSpy.callCount).to.eql(1) 186 | }) 187 | 188 | it('should start the automatic queue processing after a sleep', async () => { 189 | const queue = new Queue(priorities.CRITICAL) 190 | const taskSpy = spy(() => {}) 191 | queue.add(taskSpy) 192 | queue.sleep() 193 | await beforeNextFrame() 194 | expect(queue.size).to.eql(1) 195 | expect(taskSpy.callCount).to.eql(0) 196 | queue.start() 197 | await queue.processing() 198 | expect(queue.size).to.eql(0) 199 | expect(taskSpy.callCount).to.eql(1) 200 | }) 201 | 202 | it('should should have the same effect on multiple calls', async () => { 203 | const queue = new Queue(priorities.CRITICAL) 204 | const taskSpy = spy(() => {}) 205 | queue.add(taskSpy) 206 | queue.stop() 207 | await beforeNextFrame() 208 | expect(queue.size).to.eql(1) 209 | expect(taskSpy.callCount).to.eql(0) 210 | queue.start() 211 | queue.start() 212 | queue.start() 213 | await queue.processing() 214 | expect(queue.size).to.eql(0) 215 | expect(taskSpy.callCount).to.eql(1) 216 | }) 217 | 218 | it('should process tasks with SYNC priority', async () => { 219 | const queue = new Queue(priorities.SYNC) 220 | const taskSpy = spy(() => {}) 221 | expect(queue.size).to.eql(0) 222 | queue.stop() 223 | queue.add(taskSpy) 224 | queue.add(taskSpy) 225 | await beforeNextFrame() 226 | expect(queue.size).to.eql(1) 227 | expect(taskSpy.callCount).to.eql(0) 228 | queue.start() 229 | expect(queue.size).to.eql(0) 230 | expect(taskSpy.callCount).to.eql(1) 231 | }) 232 | }) 233 | 234 | describe('sleep', () => { 235 | it('should stop the automatic queue processing', async () => { 236 | const queue = new Queue(priorities.CRITICAL) 237 | const taskSpy = spy(() => {}) 238 | expect(queue.size).to.eql(0) 239 | queue.add(taskSpy) 240 | queue.sleep() 241 | await beforeNextFrame() 242 | expect(queue.size).to.eql(1) 243 | expect(taskSpy.callCount).to.eql(0) 244 | }) 245 | 246 | it('should discard new tasks', async () => { 247 | const queue = new Queue(priorities.CRITICAL) 248 | const taskSpy = spy(() => {}) 249 | queue.sleep() 250 | queue.add(taskSpy) 251 | expect(queue.size).to.eql(0) 252 | await beforeNextFrame() 253 | expect(taskSpy.callCount).to.eql(0) 254 | }) 255 | }) 256 | 257 | describe('process', () => { 258 | it('should process everything in the queue synchronously', () => { 259 | const queue = new Queue(priorities.HIGH) 260 | const taskSpy = spy(() => {}) 261 | queue.add(taskSpy) 262 | queue.process() 263 | expect(queue.size).to.eql(0) 264 | expect(taskSpy.callCount).to.eql(1) 265 | }) 266 | 267 | it('should process everything in stopped queues', () => { 268 | const queue = new Queue(priorities.LOW) 269 | const taskSpy = spy(() => {}) 270 | queue.add(taskSpy) 271 | queue.stop() 272 | queue.process() 273 | expect(queue.size).to.eql(0) 274 | expect(taskSpy.callCount).to.eql(1) 275 | }) 276 | }) 277 | 278 | describe('processing', () => { 279 | it('should return a Promise, which resolves when all current tasks in the queue are processed', async () => { 280 | const queue = new Queue(priorities.CRITICAL) 281 | const taskSpy1 = spy(() => {}) 282 | const taskSpy2 = spy(() => {}) 283 | queue.add(taskSpy1) 284 | queue.add(taskSpy2) 285 | await queue.processing() 286 | expect(queue.size).to.eql(0) 287 | expect(taskSpy1.callCount).to.eql(1) 288 | expect(taskSpy2.callCount).to.eql(1) 289 | }) 290 | 291 | it('should resolve immediately if the queue is empty', async () => { 292 | const queue = new Queue(priorities.LOW) 293 | // testing if this hangs 294 | await queue.processing() 295 | }) 296 | }) 297 | }) 298 | --------------------------------------------------------------------------------