├── logo.png ├── src ├── index.ts ├── work.ts ├── job.ts └── task.ts ├── playwright ├── index.ts ├── index.tsx └── index.html ├── .gitignore ├── tests ├── tsconfig.json ├── browser │ └── task.test.tsx ├── fixtures │ └── todo-list.tsx └── vitest │ ├── job.test.ts │ └── task.test.ts ├── vite.config.ts ├── .github ├── dependabot.yml └── workflows │ ├── ci.js.yml │ ├── npm-publish.yml │ └── codeql-analysis.yml ├── tsconfig.json ├── LICENSE ├── playwright-ct.config.ts ├── README.md ├── docs ├── job.md └── task.md └── package.json /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Exelord/solid-tasks/HEAD/logo.png -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./job"; 2 | export * from "./task"; 3 | export * from "./work"; 4 | -------------------------------------------------------------------------------- /playwright/index.ts: -------------------------------------------------------------------------------- 1 | // Import styles, initialize component theme here. 2 | // import '../src/common.css'; 3 | -------------------------------------------------------------------------------- /playwright/index.tsx: -------------------------------------------------------------------------------- 1 | // Import styles, initialize component theme here. 2 | // import '../src/common.css'; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | coverage 7 | /test-results/ 8 | /blob-report/ 9 | /playwright-report/ 10 | /playwright/.cache/ 11 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "preserve", 5 | "jsxImportSource": "solid-js", 6 | "baseUrl": "." 7 | }, 8 | "include": ["./", "../src"] 9 | } 10 | -------------------------------------------------------------------------------- /playwright/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Testing Page 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | build: { 5 | target: "esnext", 6 | minify: false, 7 | lib: { 8 | entry: "./src/index.ts", 9 | formats: ["cjs", "es"], 10 | }, 11 | rollupOptions: { 12 | external: ["solid-proxies", "solid-js"], 13 | }, 14 | }, 15 | resolve: { 16 | conditions: ["browser"], 17 | }, 18 | test: { 19 | dir: "./tests/vitest", 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /tests/browser/task.test.tsx: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/experimental-ct-solid"; 2 | import { TodoList } from "../fixtures/todo-list"; 3 | 4 | test("adds only one todo even when clicked twice", async ({ mount, page }) => { 5 | await mount(); 6 | 7 | const addTodo = page.getByRole("button", { name: "Add todo" }); 8 | 9 | await Promise.allSettled([addTodo.click(), addTodo.click()]); 10 | 11 | await expect(addTodo).toHaveText("Add todo"); 12 | await expect(page.getByText("✅ I have been clicked")).toHaveCount(1); 13 | }); 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | allow: 13 | - dependency-type: "production" 14 | -------------------------------------------------------------------------------- /tests/fixtures/todo-list.tsx: -------------------------------------------------------------------------------- 1 | import { createArray } from "solid-proxies"; 2 | import { createJob } from "../../src/job"; 3 | import { timeout } from "../../src/work"; 4 | 5 | export function TodoList() { 6 | const todos = createArray([]); 7 | 8 | const addTodo = createJob(async (signal) => { 9 | await timeout(signal, 1000); 10 | todos.push("✅ I have been clicked"); 11 | }); 12 | 13 | return ( 14 |
15 | 18 | 19 |
    20 | {todos.map((todo) => ( 21 |
  • {todo}
  • 22 | ))} 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | check-latest: true 22 | cache: 'npm' 23 | - run: npm ci 24 | - run: npm run build --if-present 25 | - run: npm test -- --run 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "useDefineForClassFields": true, 6 | "lib": ["ESNext", "DOM"], 7 | "moduleResolution": "Node", 8 | "outDir": "./dist/src", 9 | "strict": true, 10 | "alwaysStrict": true, 11 | "resolveJsonModule": true, 12 | "esModuleInterop": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "isolatedModules": true, 17 | "noImplicitAny": true, 18 | "strictFunctionTypes": true, 19 | "strictPropertyInitialization": true, 20 | "noImplicitThis": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedIndexedAccess": true, 23 | "strictNullChecks": true, 24 | "forceConsistentCasingInFileNames": true, 25 | "baseUrl": "./src" 26 | }, 27 | "include": ["./src"] 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | check-latest: true 19 | cache: 'npm' 20 | - run: npm ci 21 | - run: npm test 22 | 23 | publish-npm: 24 | needs: build 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version: 20 31 | check-latest: true 32 | cache: 'npm' 33 | registry-url: https://registry.npmjs.org/ 34 | - run: npm ci 35 | - run: npm publish 36 | env: 37 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Maciej Kwaśniak 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 | -------------------------------------------------------------------------------- /playwright-ct.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/experimental-ct-solid"; 2 | 3 | /** 4 | * See https://playwright.dev/docs/test-configuration. 5 | */ 6 | export default defineConfig({ 7 | testDir: "./tests/browser", 8 | /* The base directory, relative to the config file, for snapshot files created with toMatchSnapshot and toHaveScreenshot. */ 9 | snapshotDir: "./__snapshots__", 10 | /* Maximum time one test can run for. */ 11 | timeout: 10 * 1000, 12 | /* Run tests in files in parallel */ 13 | fullyParallel: true, 14 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 15 | forbidOnly: !!process.env.CI, 16 | /* Retry on CI only */ 17 | retries: process.env.CI ? 2 : 0, 18 | /* Opt out of parallel tests on CI. */ 19 | workers: process.env.CI ? 1 : undefined, 20 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 21 | reporter: "html", 22 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 23 | use: { 24 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 25 | trace: "on-first-retry", 26 | 27 | /* Port to use for Playwright component endpoint. */ 28 | ctPort: 3100, 29 | }, 30 | 31 | /* Configure projects for major browsers */ 32 | projects: [ 33 | { 34 | name: "chromium", 35 | use: { ...devices["Desktop Chrome"] }, 36 | }, 37 | ], 38 | }); 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Solid Tasks logo 3 |

4 | 5 | # Solid Tasks 6 | 7 | Solid Tasks is a package for managing and controlling concurrent operations in Solid.js applications. 8 | It provides a simple API for controlling the execution of promises and events. With Solid Tasks, you can forget about manual cancellation, concurrency side-effects and make your app user proof. 9 | 10 | ## Installation 11 | 12 | ```sh 13 | npm install solid-tasks 14 | ``` 15 | 16 | ## Requirements 17 | 18 | - Solid.js v1.0.0 or higher 19 | 20 | ## Demo 21 | 22 | - [Codesandbox](https://codesandbox.io/p/sandbox/solid-tasks-rupp9f) 23 | 24 | ## How to use it? 25 | 26 | ## Drop mode 27 | 28 | ```tsx 29 | import { createJob, work } from "solid-tasks"; 30 | 31 | const saveDataJob = createJob(async (signal) => { 32 | await work(signal, saveData) 33 | console.log('Data saved'); 34 | }, { mode: "drop"}); 35 | 36 | saveDataJob.perform(); // Task1: Pending... 37 | saveDataJob.perform(); // Task2: Aborted. Another task is pending. 38 | ``` 39 | 40 | ## Restart mode 41 | 42 | ```tsx 43 | import { createJob, work } from "solid-tasks"; 44 | 45 | const saveDataJob = createJob(async (signal) => { 46 | await work(signal, saveData) 47 | console.log('Data saved'); 48 | }, { mode: "restart"}); 49 | 50 | saveDataJob.perform(); // Task1: Pending... 51 | saveDataJob.perform(); // Task2: Aborting Task1. Pending... 52 | ``` 53 | -------------------------------------------------------------------------------- /docs/job.md: -------------------------------------------------------------------------------- 1 | # Job 2 | 3 | A job is a 4 | 5 | ## API 6 | 7 | ### Properties 8 | 9 | - `value` - The result of a successful Task execution. 10 | - `error` - The reason why a Task execution was unsuccessful. 11 | - `status` - The current status of the Task. Can be: `idle`, `pending`, `fulfilled`, `rejected`, or `aborted`. 12 | - `isIdle` - A boolean indicating whether the Task is idle. 13 | - `isPending` - A boolean indicating whether the Task is pending. 14 | - `isFulfilled` - A boolean indicating whether the Task was successful. 15 | - `isRejected` - A boolean indicating whether the Task was unsuccessful. 16 | - `isSettled` - A boolean indicating whether the Task has completed (fulfilled or rejected). 17 | - `isAborted` - A boolean indicating whether the Task was cancelled. 18 | - `signal` - An AbortSignal object for interpolation with signal-supported solution. 19 | 20 | ### Methods 21 | 22 | - `perform()` - Starts execution of the Task. 23 | - `abort(cancelReason)` - Aborts the Task with an optional cancel reason. 24 | - `then(onFulfilled, onRejected)` - Registers callbacks to be called when the Task is fulfilled or rejected. 25 | - `catch(onRejected)` - Registers a callback to be called when the Task is rejected. 26 | - `finally(onFinally)` - Registers a callback to be called when the Task is settled. 27 | - `addEventListener(type, listener, options)` - Registers an event listener for Task events. 28 | - `removeEventListener(type, listener, options)` - Removes an event listener for Task events. 29 | 30 | ### Events 31 | 32 | - `fulfill` - Triggered when the Task is fulfilled. 33 | - `reject` - Triggered when the Task is rejected. 34 | - `abort` - Triggered when the Task is cancelled. 35 | -------------------------------------------------------------------------------- /src/work.ts: -------------------------------------------------------------------------------- 1 | function abortablePromise(signal: AbortSignal) { 2 | let reject!: (reason?: any) => void; 3 | 4 | const promise = new Promise((_, reject_) => { 5 | reject = reject_; 6 | }); 7 | 8 | const callback = () => { 9 | reject(signal.reason); 10 | }; 11 | 12 | signal.addEventListener("abort", callback, { 13 | once: true, 14 | passive: true, 15 | }); 16 | 17 | return { 18 | promise, 19 | abort(): void { 20 | signal.removeEventListener("abort", callback); 21 | reject(); 22 | }, 23 | }; 24 | } 25 | 26 | /** 27 | * Run a promise with an abort signal. 28 | * @param signal An abort signal. 29 | * @param promise A promise to run. 30 | * @returns A promise that resolves when the given promise resolves or the abort signal is aborted. 31 | * @throws If the abort signal is aborted. 32 | * @example 33 | * ```ts 34 | * const controller = new AbortController(); 35 | * const promise = new Promise((resolve) => setTimeout(resolve, 1000)); 36 | * 37 | * await work(controller.signal, promise); 38 | * ``` 39 | */ 40 | export async function work(signal: AbortSignal, promise: Promise) { 41 | signal.throwIfAborted(); 42 | 43 | const controlledPromise = abortablePromise(signal); 44 | 45 | try { 46 | return await Promise.race([controlledPromise.promise, promise]); 47 | } finally { 48 | controlledPromise.abort(); 49 | } 50 | } 51 | 52 | /** 53 | * Creates abortable timeout. 54 | * @param signal An abort signal. 55 | * @param ms The number of milliseconds to wait before resolving the promise. 56 | * @returns A promise that resolves after the given number of milliseconds or the abort signal is aborted. 57 | * @throws If the abort signal is aborted. 58 | * @example 59 | * ```ts 60 | * const controller = new AbortController(); 61 | * 62 | * await timeout(controller.signal, 1000); 63 | * ``` 64 | */ 65 | export async function timeout(signal: AbortSignal, ms: number): Promise { 66 | return work(signal, new Promise((resolve) => setTimeout(resolve, ms))); 67 | } 68 | -------------------------------------------------------------------------------- /docs/task.md: -------------------------------------------------------------------------------- 1 | # Task 2 | 3 | A Task represents an asynchronous computation. It is a wrapper around a Promise that provides additional functionality and events. It can be performed only once and it manages the state of the promise. The task has built-in cancellation mechanism. 4 | 5 | ## API 6 | 7 | ### Construction 8 | 9 | ```ts 10 | import { createTask } from 'solid-tasks'; 11 | 12 | const task = createTask(async (signal) => { 13 | const response = await fetch('...', { signal }) 14 | return response.json(); 15 | }); 16 | ``` 17 | 18 | ### Properties 19 | 20 | - `value` - The result of a successful Task execution. 21 | - `error` - The reason why a Task execution was unsuccessful. 22 | - `status` - The current status of the Task. Can be: `idle`, `pending`, `fulfilled`, `rejected`, or `aborted`. 23 | - `isIdle` - A boolean indicating whether the Task is idle. 24 | - `isPending` - A boolean indicating whether the Task is pending. 25 | - `isFulfilled` - A boolean indicating whether the Task was successful. 26 | - `isRejected` - A boolean indicating whether the Task was unsuccessful. 27 | - `isSettled` - A boolean indicating whether the Task has completed (fulfilled or rejected). 28 | - `isAborted` - A boolean indicating whether the Task was cancelled. 29 | - `signal` - An AbortSignal object for interpolation with signal-supported solution. 30 | 31 | ### Methods 32 | 33 | - `perform()` - Starts execution of the Task. 34 | - `abort(cancelReason)` - Aborts the Task with an optional cancel reason. 35 | - `then(onFulfilled, onRejected)` - Registers callbacks to be called when the Task is fulfilled or rejected. 36 | - `catch(onRejected)` - Registers a callback to be called when the Task is rejected. 37 | - `finally(onFinally)` - Registers a callback to be called when the Task is settled. 38 | - `addEventListener(type, listener, options)` - Registers an event listener for Task events. 39 | - `removeEventListener(type, listener, options)` - Removes an event listener for Task events. 40 | 41 | ### Events 42 | 43 | - `fulfill` - Triggered when the Task is fulfilled. 44 | - `reject` - Triggered when the Task is rejected. 45 | - `abort` - Triggered when the Task is cancelled. 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "solid-tasks", 3 | "description": "Manage and control concurrent and async operations in Solid.js apps. Forget about manual cancellation, concurrency side-effects and make your app user proof.", 4 | "version": "0.2.0", 5 | "license": "MIT", 6 | "homepage": "https://github.com/Exelord/solid-tasks", 7 | "repository": "github:exelord/solid-tasks", 8 | "keywords": [ 9 | "solidjs", 10 | "tasks", 11 | "jobs", 12 | "concurrency", 13 | "promises", 14 | "actions" 15 | ], 16 | "author": { 17 | "name": "Maciej Kwaśniak", 18 | "email": "contact@exelord.com", 19 | "url": "https://exelord.com" 20 | }, 21 | "files": [ 22 | "dist" 23 | ], 24 | "main": "./dist/solid-tasks.cjs", 25 | "module": "./dist/solid-tasks.js", 26 | "types": "./dist/types/index.d.ts", 27 | "source": "./dist/src/index.js", 28 | "sideEffects": false, 29 | "type": "module", 30 | "exports": { 31 | ".": { 32 | "types": "./dist/types/index.d.ts", 33 | "import": "./dist/solid-tasks.js", 34 | "require": "./dist/solid-tasks.cjs" 35 | } 36 | }, 37 | "scripts": { 38 | "test": "vitest", 39 | "coverage": "vitest run --coverage", 40 | "prepublishOnly": "npm run build", 41 | "dev": "npm-run-all --parallel 'build:** -- --watch'", 42 | "build": "npm-run-all --parallel build:** && npm run types", 43 | "build:js": "vite build", 44 | "build:source": "tsc", 45 | "types": "tsc --emitDeclarationOnly --declaration --outDir ./dist/types", 46 | "release": "release-it", 47 | "test-ct": "playwright test -c playwright-ct.config.ts" 48 | }, 49 | "release-it": { 50 | "git": { 51 | "commitMessage": "v${version}", 52 | "tagAnnotation": "v${version}" 53 | }, 54 | "npm": { 55 | "publish": false 56 | }, 57 | "github": { 58 | "release": true, 59 | "releaseName": "v${version}" 60 | }, 61 | "hooks": { 62 | "before:init": [ 63 | "vitest run" 64 | ] 65 | } 66 | }, 67 | "peerDependencies": { 68 | "solid-js": "^1.7.0" 69 | }, 70 | "dependencies": { 71 | "solid-proxies": "^1.0.2" 72 | }, 73 | "devDependencies": { 74 | "@playwright/experimental-ct-solid": "^1.39.0", 75 | "@playwright/test": "^1.39.0", 76 | "@types/node": "^20.8.10", 77 | "@vitest/browser": "^0.34.6", 78 | "@vitest/coverage-c8": "^0.33.0", 79 | "@vitest/ui": "^0.34.6", 80 | "npm-run-all": "^4.1.5", 81 | "release-it": "^16.2.1", 82 | "solid-js": "^1.7.0", 83 | "typescript": "^5.2.2", 84 | "vite": "^4.5.0", 85 | "vitest": "^0.34.6" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '21 9 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /tests/vitest/job.test.ts: -------------------------------------------------------------------------------- 1 | import { work, timeout } from "../../src/work"; 2 | import { createJob, JobMode } from "../../src/job"; 3 | import { describe, test, expect } from "vitest"; 4 | import { createRoot, getOwner } from "solid-js"; 5 | 6 | describe("job", () => { 7 | describe("#perform", () => { 8 | test("drop", async () => { 9 | const job = createJob(async (signal) => { 10 | await timeout(signal, 1); 11 | return "Hello World"; 12 | }); 13 | 14 | expect(job.performCount).toBe(0); 15 | expect(job.isIdle).toBe(true); 16 | 17 | const task1 = job.perform(); 18 | 19 | expect(job.performCount).toBe(1); 20 | expect(job.isPending).toBe(true); 21 | expect(job.lastPending).toBe(task1); 22 | 23 | const task2 = job.perform(); 24 | 25 | expect(job.performCount).toBe(2); 26 | expect(job.isPending).toBe(true); 27 | 28 | await job.lastPending; 29 | 30 | expect(job.isIdle).toBe(true); 31 | expect(task1.isFulfilled).toBe(true); 32 | expect(task2.isAborted).toBe(true); 33 | 34 | expect(job.lastFulfilled).toBe(task1); 35 | expect(job.lastSettled).toBe(task1); 36 | expect(job.lastAborted).toBe(task2); 37 | expect(job.lastRejected).toBe(undefined); 38 | expect(job.lastPending).toBe(undefined); 39 | }); 40 | 41 | test("restart", async () => { 42 | const job = createJob( 43 | async (signal) => { 44 | await timeout(signal, 1); 45 | return "Hello World"; 46 | }, 47 | { mode: JobMode.Restart } 48 | ); 49 | 50 | expect(job.performCount).toBe(0); 51 | expect(job.isIdle).toBe(true); 52 | 53 | const task1 = job.perform(); 54 | 55 | expect(job.performCount).toBe(1); 56 | expect(job.isPending).toBe(true); 57 | expect(job.lastPending).toBe(task1); 58 | 59 | const task2 = job.perform(); 60 | 61 | expect(job.performCount).toBe(2); 62 | expect(job.isPending).toBe(true); 63 | expect(job.lastPending).toBe(task2); 64 | 65 | await job.lastPending; 66 | 67 | expect(job.isIdle).toBe(true); 68 | expect(task1.isAborted).toBe(true); 69 | expect(task2.isFulfilled).toBe(true); 70 | 71 | expect(job.performCount).toBe(2); 72 | expect(job.lastFulfilled).toBe(task2); 73 | expect(job.lastSettled).toBe(task2); 74 | expect(job.lastAborted).toBe(task1); 75 | expect(job.lastRejected).toBe(undefined); 76 | expect(job.lastPending).toBe(undefined); 77 | }); 78 | }); 79 | 80 | test("#abort", async () => { 81 | const job = createJob(async (signal) => { 82 | await work(signal, new Promise(() => {})); 83 | }); 84 | 85 | expect(job.performCount).toBe(0); 86 | expect(job.isIdle).toBe(true); 87 | 88 | const task = job.perform(); 89 | 90 | expect(job.performCount).toBe(1); 91 | expect(job.isPending).toBe(true); 92 | 93 | await job.abort(); 94 | 95 | expect(job.isIdle).toBe(true); 96 | expect(task.isAborted).toBe(true); 97 | 98 | expect(job.lastAborted).toBe(task); 99 | expect(job.lastFulfilled).toBe(undefined); 100 | expect(job.lastSettled).toBe(undefined); 101 | expect(job.lastRejected).toBe(undefined); 102 | expect(job.lastPending).toBe(undefined); 103 | }); 104 | 105 | test("aborts all tasks on cleanup", async () => { 106 | await createRoot(async (cleanup) => { 107 | const job = createJob(async (signal) => { 108 | await work(signal, new Promise(() => {})); 109 | }); 110 | 111 | job.perform(); 112 | 113 | expect(job.isPending).toBe(true); 114 | 115 | cleanup(); 116 | 117 | await new Promise(process.nextTick); 118 | 119 | expect(job.isPending).toBe(false); 120 | }); 121 | }); 122 | 123 | test("runs without owner", async () => { 124 | await createRoot(async () => { 125 | const job = createJob(async () => { 126 | expect(getOwner()).toBe(null); 127 | }); 128 | 129 | expect(getOwner()).not.toBe(null); 130 | await job.perform(); 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /tests/vitest/task.test.ts: -------------------------------------------------------------------------------- 1 | import { createTask, TaskAbortError, TaskStatus } from "../../src/task"; 2 | import { describe, test, expect, vi } from "vitest"; 3 | 4 | describe("Task", () => { 5 | describe("#perform", async () => { 6 | test("fulfilled", async () => { 7 | const task = createTask(() => Promise.resolve("Hello World")); 8 | 9 | expect(task.status).toBe(TaskStatus.Idle); 10 | expect(task.isIdle).toBe(true); 11 | 12 | task.perform(); 13 | 14 | expect(task.status).toBe(TaskStatus.Pending); 15 | expect(task.isPending).toBe(true); 16 | 17 | await task; 18 | 19 | expect(task.status).toBe(TaskStatus.Fulfilled); 20 | expect(task.isFulfilled).toBe(true); 21 | expect(task.isSettled).toBe(true); 22 | expect(task.value).toBe("Hello World"); 23 | }); 24 | 25 | test("rejected", async () => { 26 | const error = new Error("Something went wrong"); 27 | const task = createTask(() => Promise.reject(error)); 28 | 29 | expect(task.status).toBe(TaskStatus.Idle); 30 | expect(task.isIdle).toBe(true); 31 | 32 | task.perform(); 33 | 34 | expect(task.status).toBe(TaskStatus.Pending); 35 | expect(task.isPending).toBe(true); 36 | 37 | await expect(task).rejects.toThrow("Something went wrong"); 38 | 39 | expect(task.status).toBe(TaskStatus.Rejected); 40 | expect(task.isRejected).toBe(true); 41 | expect(task.isSettled).toBe(true); 42 | expect(task.error).toBe(error); 43 | }); 44 | }); 45 | 46 | describe("#abort", async () => { 47 | test("aborting pending task", async () => { 48 | const task = createTask(() => new Promise(() => {})); 49 | 50 | expect(task.status).toBe(TaskStatus.Idle); 51 | 52 | task.perform(); 53 | 54 | expect(task.status).toBe(TaskStatus.Pending); 55 | 56 | await task.abort(); 57 | 58 | expect(task.status).toBe(TaskStatus.Aborted); 59 | expect(task.error).toBeInstanceOf(TaskAbortError); 60 | }); 61 | 62 | test("aborting idle task", async () => { 63 | const task = createTask(() => new Promise(() => {})); 64 | 65 | expect(task.status).toBe(TaskStatus.Idle); 66 | 67 | await task.abort(); 68 | 69 | expect(task.status).toBe(TaskStatus.Aborted); 70 | 71 | await expect(task).rejects.toThrow("The task was aborted."); 72 | 73 | expect(task.status).toBe(TaskStatus.Aborted); 74 | expect(task.error).toBeInstanceOf(TaskAbortError); 75 | }); 76 | }); 77 | 78 | describe("#addEventListener", async () => { 79 | test("abort", async () => { 80 | const task = createTask(() => new Promise(() => {})); 81 | const listener = vi.fn(); 82 | 83 | task.addEventListener("abort", listener); 84 | 85 | task.perform(); 86 | 87 | await task.abort(); 88 | 89 | expect(listener).toHaveBeenCalledTimes(1); 90 | }); 91 | 92 | test("fulfill", async () => { 93 | const task = createTask(() => Promise.resolve("Hello World")); 94 | const listener = vi.fn(); 95 | 96 | task.addEventListener("fulfill", listener); 97 | 98 | task.perform(); 99 | 100 | await task; 101 | 102 | expect(listener).toHaveBeenCalledTimes(1); 103 | }); 104 | 105 | test("reject", async () => { 106 | const error = new Error("Something went wrong"); 107 | const task = createTask(() => Promise.reject(error)); 108 | const listener = vi.fn(); 109 | 110 | task.addEventListener("reject", listener); 111 | 112 | task.perform(); 113 | 114 | await expect(task).rejects.toThrow("Something went wrong"); 115 | 116 | expect(listener).toHaveBeenCalledTimes(1); 117 | }); 118 | }); 119 | 120 | describe("#removeEventListener", async () => { 121 | test("abort", async () => { 122 | const task = createTask(() => new Promise(() => {})); 123 | const listener = vi.fn(); 124 | 125 | task.addEventListener("abort", listener); 126 | task.removeEventListener("abort", listener); 127 | 128 | task.perform(); 129 | 130 | await task.abort(); 131 | 132 | expect(listener).not.toHaveBeenCalled(); 133 | }); 134 | 135 | test("fulfill", async () => { 136 | const task = createTask(() => Promise.resolve("Hello World")); 137 | const listener = vi.fn(); 138 | 139 | task.addEventListener("fulfill", listener); 140 | task.removeEventListener("fulfill", listener); 141 | 142 | task.perform(); 143 | 144 | await task; 145 | 146 | expect(listener).not.toHaveBeenCalled(); 147 | }); 148 | 149 | test("reject", async () => { 150 | const error = new Error("Something went wrong"); 151 | const task = createTask(() => Promise.reject(error)); 152 | const listener = vi.fn(); 153 | 154 | task.addEventListener("reject", listener); 155 | task.removeEventListener("reject", listener); 156 | 157 | task.perform(); 158 | 159 | await expect(task).rejects.toThrow("Something went wrong"); 160 | 161 | expect(listener).not.toHaveBeenCalled(); 162 | }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /src/job.ts: -------------------------------------------------------------------------------- 1 | import { getOwner, onCleanup, untrack } from "solid-js"; 2 | import { createObject } from "solid-proxies"; 3 | import { createTask, Task } from "./task"; 4 | 5 | export type TaskFunction = ( 6 | signal: AbortSignal, 7 | ...args: Args 8 | ) => Promise; 9 | 10 | export enum JobStatus { 11 | Idle = "idle", 12 | Pending = "pending", 13 | } 14 | 15 | export type JobMode = (typeof JobMode)[keyof typeof JobMode]; 16 | 17 | export interface JobOptions { 18 | mode?: JobMode; 19 | } 20 | 21 | interface ReactiveState { 22 | status: JobStatus; 23 | performCount: number; 24 | lastPending?: Task; 25 | lastFulfilled?: Task; 26 | lastRejected?: Task; 27 | lastSettled?: Task; 28 | lastAborted?: Task; 29 | } 30 | 31 | export const JobMode = { 32 | Drop: "drop", 33 | Restart: "restart", 34 | } as const; 35 | 36 | /** 37 | * A Job is a wrapper around a task function that provides 38 | * a reactive interface to the task's state. 39 | * @template T The return type of the task function. 40 | * @template Args The argument types of the task function. 41 | * @param taskFn The task function to wrap. 42 | * @param options Options for the job. 43 | * @returns A job instance. 44 | * @example 45 | * ```ts 46 | * const job = createJob(async (signal, url: string) => { 47 | * const response = await fetch(url, { signal }); 48 | * return response.json(); 49 | * }); 50 | * 51 | * const task = job.perform("https://example.test"); 52 | * 53 | * console.log(job.status); // "pending" 54 | * console.log(job.isPending); // true 55 | * console.log(job.isIdle); // false 56 | * 57 | * await task; 58 | * 59 | * console.log(job.status); // "idle" 60 | * console.log(job.isPending); // false 61 | * console.log(job.isIdle); // true 62 | * ``` 63 | */ 64 | export class Job { 65 | /** 66 | * The current status of the job. 67 | */ 68 | get status(): ReactiveState["status"] { 69 | return this.#reactiveState.status; 70 | } 71 | 72 | /** 73 | * Whether the job is currently idle. Not performing a task. 74 | */ 75 | get isIdle(): boolean { 76 | return this.status === JobStatus.Idle; 77 | } 78 | 79 | /** 80 | * Whether the job is currently pending. Performing a task. 81 | */ 82 | get isPending(): boolean { 83 | return this.status === JobStatus.Pending; 84 | } 85 | 86 | /** 87 | * Last pending task. 88 | */ 89 | get lastPending(): ReactiveState["lastPending"] { 90 | return this.#reactiveState.lastPending; 91 | } 92 | 93 | /** 94 | * Last fulfilled task. 95 | */ 96 | get lastFulfilled(): ReactiveState["lastFulfilled"] { 97 | return this.#reactiveState.lastFulfilled; 98 | } 99 | 100 | /** 101 | * Last rejected task. 102 | */ 103 | get lastRejected(): ReactiveState["lastRejected"] { 104 | return this.#reactiveState.lastRejected; 105 | } 106 | 107 | /** 108 | * Last settled task. 109 | */ 110 | get lastSettled(): ReactiveState["lastSettled"] { 111 | return this.#reactiveState.lastSettled; 112 | } 113 | 114 | /** 115 | * Last aborted task. 116 | */ 117 | get lastAborted(): ReactiveState["lastAborted"] { 118 | return this.#reactiveState.lastAborted; 119 | } 120 | 121 | /** 122 | * Number of times the job has performed a task, fulfilled or not. 123 | */ 124 | get performCount(): ReactiveState["performCount"] { 125 | return this.#reactiveState.performCount; 126 | } 127 | 128 | #taskFn: TaskFunction; 129 | #options: JobOptions; 130 | 131 | #reactiveState: ReactiveState = createObject({ 132 | status: JobStatus.Idle, 133 | performCount: 0, 134 | }); 135 | 136 | constructor(taskFn: TaskFunction, { mode }: JobOptions = {}) { 137 | this.#taskFn = taskFn; 138 | this.#options = { mode: mode ?? JobMode.Drop }; 139 | } 140 | 141 | /** 142 | * Perform a task. 143 | * @param args Arguments to pass to the task function. 144 | * @returns A task instance. 145 | */ 146 | perform(...args: Args): Task { 147 | return untrack(() => { 148 | const task = createTask((signal) => this.#taskFn(signal, ...args)); 149 | this.#instrumentTask(task); 150 | this.#reactiveState.performCount++; 151 | 152 | if (this.lastPending) { 153 | if (this.#options.mode === JobMode.Drop) { 154 | task.abort(); 155 | return task; 156 | } 157 | 158 | if (this.#options.mode === JobMode.Restart) { 159 | this.lastPending.abort(); 160 | } 161 | } 162 | 163 | task.perform(); 164 | 165 | this.#reactiveState.lastPending = task; 166 | this.#reactiveState.status = JobStatus.Pending; 167 | return task; 168 | }); 169 | } 170 | 171 | /** 172 | * Abort the last pending task. 173 | * @param reason A reason for aborting the task. 174 | * @returns A promise that resolves when the task is aborted. 175 | */ 176 | async abort(reason?: string): Promise { 177 | return untrack(() => this.lastPending?.abort(reason)); 178 | } 179 | 180 | #instrumentTask(task: Task): void { 181 | task.addEventListener("reject", () => { 182 | this.#reactiveState.lastRejected = task; 183 | this.#reactiveState.lastSettled = task; 184 | 185 | if (this.#reactiveState.lastPending === task) { 186 | this.#reactiveState.lastPending = undefined; 187 | this.#reactiveState.status = JobStatus.Idle; 188 | } 189 | }); 190 | 191 | task.addEventListener("fulfill", () => { 192 | this.#reactiveState.lastFulfilled = task; 193 | this.#reactiveState.lastSettled = task; 194 | 195 | if (this.#reactiveState.lastPending === task) { 196 | this.#reactiveState.lastPending = undefined; 197 | this.#reactiveState.status = JobStatus.Idle; 198 | } 199 | }); 200 | 201 | task.addEventListener("abort", () => { 202 | this.#reactiveState.lastAborted = task; 203 | 204 | if (this.#reactiveState.lastPending === task) { 205 | this.#reactiveState.lastPending = undefined; 206 | this.#reactiveState.status = JobStatus.Idle; 207 | } 208 | }); 209 | } 210 | } 211 | 212 | /** 213 | * Create a job. 214 | * @template T The return type of the task function. 215 | * @template Args The argument types of the task function. 216 | * @param taskFn The task function to wrap. 217 | * @param options Options for the job. 218 | * @returns A job instance. 219 | */ 220 | export function createJob( 221 | taskFn: TaskFunction, 222 | options: JobOptions = {} 223 | ): Job { 224 | const job = new Job(taskFn, options); 225 | 226 | if (getOwner()) { 227 | onCleanup(() => { 228 | job.abort(); 229 | }); 230 | } 231 | 232 | return job; 233 | } 234 | -------------------------------------------------------------------------------- /src/task.ts: -------------------------------------------------------------------------------- 1 | import { runWithOwner, untrack } from "solid-js"; 2 | import { createObject } from "solid-proxies"; 3 | import { work } from "./work"; 4 | 5 | export enum TaskStatus { 6 | Idle = "idle", 7 | Pending = "pending", 8 | Fulfilled = "fulfilled", 9 | Rejected = "rejected", 10 | Aborted = "aborted", 11 | } 12 | 13 | /** 14 | * An error that is thrown when a task is aborted. 15 | */ 16 | export class TaskAbortError extends Error { 17 | name = "TaskAbortError"; 18 | } 19 | 20 | /** 21 | * A task is a promise that can be aborted, aware of its state. 22 | */ 23 | export class Task implements Promise { 24 | /** 25 | * The current value of the task. 26 | */ 27 | get value(): T | null | undefined { 28 | return this.#reactiveState.value; 29 | } 30 | 31 | /** 32 | * The current error of the task. 33 | */ 34 | get error(): unknown { 35 | return this.#reactiveState.error; 36 | } 37 | 38 | /** 39 | * Whether the task is currently idle. 40 | */ 41 | get isIdle(): boolean { 42 | return this.status === TaskStatus.Idle; 43 | } 44 | 45 | /** 46 | * Whether the task is currently pending. 47 | */ 48 | get isPending(): boolean { 49 | return this.status === TaskStatus.Pending; 50 | } 51 | 52 | /** 53 | * Whether the task is currently fulfilled. 54 | */ 55 | get isFulfilled(): boolean { 56 | return this.status === TaskStatus.Fulfilled; 57 | } 58 | 59 | /** 60 | * Whether the task is currently rejected. 61 | */ 62 | get isRejected(): boolean { 63 | return this.status === TaskStatus.Rejected; 64 | } 65 | 66 | /** 67 | * Whether the task is currently settled. 68 | */ 69 | get isSettled(): boolean { 70 | return [TaskStatus.Fulfilled, TaskStatus.Rejected].includes(this.status); 71 | } 72 | 73 | /** 74 | * Whether the task is currently aborted. 75 | */ 76 | get isAborted(): boolean { 77 | return this.status === TaskStatus.Aborted; 78 | } 79 | 80 | /** 81 | * The current status of the task. 82 | */ 83 | get status(): TaskStatus { 84 | return this.#reactiveState.status; 85 | } 86 | 87 | /** 88 | * The signal of the task. Used to abort the task. 89 | */ 90 | get signal(): AbortSignal { 91 | return this.#abortController.signal; 92 | } 93 | 94 | get [Symbol.toStringTag](): string { 95 | return "Task"; 96 | } 97 | 98 | #promise?: Promise; 99 | #promiseFn: (signal: AbortSignal) => Promise; 100 | #abortController = new AbortController(); 101 | #eventTarget = new EventTarget(); 102 | 103 | #reactiveState = createObject<{ 104 | value?: T | null; 105 | error?: unknown; 106 | status: TaskStatus; 107 | }>({ 108 | value: null, 109 | status: TaskStatus.Idle, 110 | }); 111 | 112 | constructor(promiseFn: (signal: AbortSignal) => Promise) { 113 | this.#promiseFn = (signal) => runWithOwner(null, () => promiseFn(signal))!; 114 | } 115 | 116 | then( 117 | onfulfilled?: 118 | | ((value: T) => TResult1 | PromiseLike) 119 | | null 120 | | undefined, 121 | onrejected?: 122 | | ((reason: any) => TResult2 | PromiseLike) 123 | | null 124 | | undefined 125 | ): Promise { 126 | return this.#execute().then(onfulfilled, onrejected); 127 | } 128 | 129 | catch( 130 | onrejected?: 131 | | ((reason: any) => TResult | PromiseLike) 132 | | null 133 | | undefined 134 | ): Promise { 135 | return this.#execute().catch(onrejected); 136 | } 137 | 138 | finally(onfinally?: (() => void) | null | undefined): Promise { 139 | return this.#execute().finally(onfinally); 140 | } 141 | 142 | addEventListener( 143 | type: "abort" | "fulfill" | "reject", 144 | listener: (event: Event) => void, 145 | options?: boolean | AddEventListenerOptions 146 | ): void { 147 | if (typeof options === "boolean") { 148 | options = { capture: options }; 149 | } 150 | 151 | this.#eventTarget.addEventListener(type, listener, { 152 | signal: type === "abort" ? undefined : this.signal, 153 | once: true, 154 | passive: true, 155 | ...options, 156 | }); 157 | } 158 | 159 | removeEventListener( 160 | type: "abort" | "fulfill" | "reject", 161 | listener: (event: Event) => void, 162 | options?: boolean | EventListenerOptions 163 | ): void { 164 | this.#eventTarget.removeEventListener(type, listener, options); 165 | } 166 | 167 | #dispatchEvent(type: "abort" | "fulfill" | "reject"): void { 168 | this.#eventTarget.dispatchEvent(new Event(type)); 169 | } 170 | 171 | /** 172 | * Aborts the task. 173 | */ 174 | abort(cancelReason = "The task was aborted."): Promise { 175 | return untrack(async () => { 176 | if (!this.isIdle && !this.isPending) return; 177 | 178 | const error = new TaskAbortError(cancelReason); 179 | this.#abortController.abort(error); 180 | if (this.isIdle) this.#handleFailure(error); 181 | 182 | try { 183 | await this.#promise; 184 | } catch (error) { 185 | if (error instanceof TaskAbortError) return; 186 | throw error; 187 | } 188 | }); 189 | } 190 | 191 | /** 192 | * Performs the task. 193 | */ 194 | perform(): Task { 195 | this.#execute(); 196 | return this; 197 | } 198 | 199 | #execute(): Promise { 200 | this.#promise ??= untrack(() => this.#resolve()); 201 | return this.#promise; 202 | } 203 | 204 | async #resolve(): Promise { 205 | try { 206 | this.#abortController.signal.throwIfAborted(); 207 | this.#reactiveState.status = TaskStatus.Pending; 208 | 209 | const value = await work( 210 | this.#abortController.signal, 211 | this.#promiseFn(this.#abortController.signal) 212 | ); 213 | 214 | this.#handleSuccess(value); 215 | 216 | return value; 217 | } catch (error) { 218 | this.#handleFailure(error); 219 | throw error; 220 | } 221 | } 222 | 223 | #handleFailure(error: this["error"]): void { 224 | this.#reactiveState.error = error; 225 | 226 | if (error instanceof TaskAbortError) { 227 | this.#reactiveState.status = TaskStatus.Aborted; 228 | this.#dispatchEvent("abort"); 229 | } else { 230 | this.#reactiveState.status = TaskStatus.Rejected; 231 | this.#dispatchEvent("reject"); 232 | } 233 | } 234 | 235 | #handleSuccess(value: this["value"]): void { 236 | this.#reactiveState.value = value; 237 | this.#reactiveState.status = TaskStatus.Fulfilled; 238 | this.#dispatchEvent("fulfill"); 239 | } 240 | } 241 | 242 | export function createTask( 243 | promiseFn: (signal: AbortSignal) => Promise 244 | ): Task { 245 | return new Task(promiseFn); 246 | } 247 | --------------------------------------------------------------------------------