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