From 4eaef11ab4ffcad83ca5f61fa09292851049a34a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Kwa=C5=9Bniak?= Date: Sun, 7 Jun 2026 21:12:39 +0200 Subject: [PATCH 1/5] chore: migrate tooling to Vite+ (tsdown, oxlint, vp test) Replace the vite/tsc/playwright build-and-test stack with the Vite+ (`vp`) toolchain: tsdown for packaging, oxlint for linting, and vp test for the unit suite. Switch tsconfig to bundler module resolution (the project is bundled, not run raw in Node), update the CI workflows, and drop the Playwright component-test setup. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.js.yml | 22 +- .github/workflows/codeql-analysis.yml | 58 +- .github/workflows/npm-publish.yml | 22 +- .gitignore | 2 - CLAUDE.md | 16 + package-lock.json | 10198 ++++++++---------------- package.json | 86 +- playwright-ct.config.ts | 38 - playwright/index.html | 12 - playwright/index.ts | 2 - playwright/index.tsx | 2 - src/index.ts | 6 +- tests/browser/task.test.tsx | 13 - tests/fixtures/todo-list.tsx | 26 - tests/tsconfig.json | 9 - tsconfig.json | 36 +- vite.config.ts | 26 +- 17 files changed, 3466 insertions(+), 7108 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 playwright-ct.config.ts delete mode 100644 playwright/index.html delete mode 100644 playwright/index.ts delete mode 100644 playwright/index.tsx delete mode 100644 tests/browser/task.test.tsx delete mode 100644 tests/fixtures/todo-list.tsx delete mode 100644 tests/tsconfig.json diff --git a/.github/workflows/ci.js.yml b/.github/workflows/ci.js.yml index 2f7a75b..6d00dab 100644 --- a/.github/workflows/ci.js.yml +++ b/.github/workflows/ci.js.yml @@ -5,21 +5,21 @@ name: CI on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - check-latest: true - cache: 'npm' - - run: npm ci - - run: npm run build --if-present - - run: npm test -- --run + - uses: actions/checkout@v4 + - uses: voidzero-dev/setup-vp@v1 + with: + node-version: "24" + cache: true + - run: vp install + - run: vp check + - run: vp test + - run: vp pack diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f009d0f..6103bc5 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,12 +13,12 @@ name: "CodeQL" on: push: - branches: [ main ] + branches: [main] pull_request: # The branches below must be a subset of the branches above - branches: [ main ] + branches: [main] schedule: - - cron: '21 9 * * 1' + - cron: "21 9 * * 1" jobs: analyze: @@ -32,39 +32,39 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'javascript' ] + language: ["javascript"] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://git.io/codeql-language-support steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language - #- run: | - # make bootstrap - # make release + #- run: | + # make bootstrap + # make release - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 4de2ec3..b446371 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -12,26 +12,28 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: voidzero-dev/setup-vp@v1 with: - node-version: 20 - check-latest: true - cache: 'npm' - - run: npm ci - - run: npm test + node-version: "24" + cache: true + - run: vp install + - run: vp check + - run: vp test + - run: vp pack publish-npm: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: voidzero-dev/setup-vp@v1 with: - node-version: 20 + node-version: "24" check-latest: true - cache: 'npm' registry-url: https://registry.npmjs.org/ - - run: npm ci + cache: true + - run: vp install - run: npm publish env: NODE_AUTH_TOKEN: ${{secrets.npm_token}} diff --git a/.gitignore b/.gitignore index 922422b..f4f2734 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,3 @@ dist-ssr coverage /test-results/ /blob-report/ -/playwright-report/ -/playwright/.cache/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..362b82a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,16 @@ + + +# Using Vite+, the Unified Toolchain for the Web + +This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, and it invokes Vite through `vp dev` and `vp build`. Run `vp help` to print a list of commands and `vp --help` for information about a specific command. + +Docs are local at `node_modules/vite-plus/docs` or online at https://viteplus.dev/guide/. + +## Review Checklist + +- [ ] Run `vp install` after pulling remote changes and before getting started. +- [ ] Run `vp check` and `vp test` to format, lint, type check and test changes. +- [ ] Check if there are `vite.config.ts` tasks or `package.json` scripts necessary for validation, run via `vp run - - diff --git a/playwright/index.ts b/playwright/index.ts deleted file mode 100644 index ac6de14..0000000 --- a/playwright/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Import styles, initialize component theme here. -// import '../src/common.css'; diff --git a/playwright/index.tsx b/playwright/index.tsx deleted file mode 100644 index ac6de14..0000000 --- a/playwright/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Import styles, initialize component theme here. -// import '../src/common.css'; diff --git a/src/index.ts b/src/index.ts index 93a4ddb..4e762d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ -export * from "./job"; -export * from "./task"; -export * from "./work"; +export * from "./job.ts"; +export * from "./task.ts"; +export * from "./work.ts"; diff --git a/tests/browser/task.test.tsx b/tests/browser/task.test.tsx deleted file mode 100644 index 1c0d457..0000000 --- a/tests/browser/task.test.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { test, expect } from "@playwright/experimental-ct-solid"; -import { TodoList } from "../fixtures/todo-list"; - -test("adds only one todo even when clicked twice", async ({ mount, page }) => { - await mount(); - - const addTodo = page.getByRole("button", { name: "Add todo" }); - - await Promise.allSettled([addTodo.click(), addTodo.click()]); - - await expect(addTodo).toHaveText("Add todo"); - await expect(page.getByText("✅ I have been clicked")).toHaveCount(1); -}); diff --git a/tests/fixtures/todo-list.tsx b/tests/fixtures/todo-list.tsx deleted file mode 100644 index 1d0b425..0000000 --- a/tests/fixtures/todo-list.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { createArray } from "solid-proxies"; -import { createJob } from "../../src/job"; -import { timeout } from "../../src/work"; - -export function TodoList() { - const todos = createArray([]); - - const addTodo = createJob(async (signal) => { - await timeout(signal, 1000); - todos.push("✅ I have been clicked"); - }); - - return ( -
- - -
    - {todos.map((todo) => ( -
  • {todo}
  • - ))} -
-
- ); -} diff --git a/tests/tsconfig.json b/tests/tsconfig.json deleted file mode 100644 index e5aaf9d..0000000 --- a/tests/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "jsx": "preserve", - "jsxImportSource": "solid-js", - "baseUrl": "." - }, - "include": ["./", "../src"] -} diff --git a/tsconfig.json b/tsconfig.json index be80330..77ae2b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,28 +1,20 @@ { "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "useDefineForClassFields": true, - "lib": ["ESNext", "DOM"], - "moduleResolution": "Node", - "outDir": "./dist/src", - "strict": true, - "alwaysStrict": true, + "target": "esnext", + "lib": ["ESNext"], + "moduleDetection": "force", + "module": "preserve", + "moduleResolution": "bundler", "resolveJsonModule": true, - "esModuleInterop": true, + "types": ["node"], + "strict": true, "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitReturns": true, + "declaration": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, "isolatedModules": true, - "noImplicitAny": true, - "strictFunctionTypes": true, - "strictPropertyInitialization": true, - "noImplicitThis": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "strictNullChecks": true, - "forceConsistentCasingInFileNames": true, - "baseUrl": "./src" - }, - "include": ["./src"] + "verbatimModuleSyntax": true, + "skipLibCheck": true + } } diff --git a/vite.config.ts b/vite.config.ts index 95d94b2..bffbbc1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,21 +1,17 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig } from "vite-plus"; export default defineConfig({ - build: { - target: "esnext", - minify: false, - lib: { - entry: "./src/index.ts", - formats: ["cjs", "es"], + pack: { + dts: { + tsgo: true, }, - rollupOptions: { - external: ["solid-proxies", "solid-js"], - }, - }, - resolve: { - conditions: ["browser"], + exports: true, }, - test: { - dir: "./tests/vitest", + lint: { + options: { + typeAware: true, + typeCheck: true, + }, }, + fmt: {}, }); From 5de1a0aa21aa7c842bc4314810741515e49b5480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Kwa=C5=9Bniak?= Date: Sun, 7 Jun 2026 21:12:54 +0200 Subject: [PATCH 2/5] feat: add Task#abortOnSignal and harden lifecycle semantics - Add Task#abortOnSignal(signal): cancel a task when an external AbortSignal aborts, with the listener auto-removed once the task settles (no leak on long-lived signals). - Treat any post-abort failure as a TaskAbortError, so abort() never rethrows and an aborted task settles consistently regardless of what the body threw. - Replay the terminal lifecycle event to listeners subscribed after the task has already settled (promise-like). - timeout(): clear the pending timer on abort so it no longer holds the event loop open for the remainder of the delay. - Simplify event dispatch (derive the event from status via a single latch) and deduplicate the Job task instrumentation. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/job.ts | 33 +++++------ src/task.ts | 157 +++++++++++++++++++++++++++++++++++----------------- src/work.ts | 54 ++++++++---------- 3 files changed, 143 insertions(+), 101 deletions(-) diff --git a/src/job.ts b/src/job.ts index 9415689..6827d2d 100644 --- a/src/job.ts +++ b/src/job.ts @@ -1,6 +1,6 @@ import { getOwner, onCleanup, untrack } from "solid-js"; import { createObject } from "solid-proxies"; -import { createTask, Task } from "./task"; +import { createTask, Task } from "./task.ts"; export type TaskFunction = ( signal: AbortSignal, @@ -151,12 +151,12 @@ export class Job { if (this.lastPending) { if (this.#options.mode === JobMode.Drop) { - task.abort(); + void task.abort(); return task; } if (this.#options.mode === JobMode.Restart) { - this.lastPending.abort(); + void this.lastPending.abort(); } } @@ -178,33 +178,28 @@ export class Job { } #instrumentTask(task: Task): void { - task.addEventListener("reject", () => { - this.#reactiveState.lastRejected = task; - this.#reactiveState.lastSettled = task; - + const clearIfPending = () => { if (this.#reactiveState.lastPending === task) { this.#reactiveState.lastPending = undefined; this.#reactiveState.status = JobStatus.Idle; } - }); + }; task.addEventListener("fulfill", () => { this.#reactiveState.lastFulfilled = task; this.#reactiveState.lastSettled = task; + clearIfPending(); + }); - if (this.#reactiveState.lastPending === task) { - this.#reactiveState.lastPending = undefined; - this.#reactiveState.status = JobStatus.Idle; - } + task.addEventListener("reject", () => { + this.#reactiveState.lastRejected = task; + this.#reactiveState.lastSettled = task; + clearIfPending(); }); task.addEventListener("abort", () => { this.#reactiveState.lastAborted = task; - - if (this.#reactiveState.lastPending === task) { - this.#reactiveState.lastPending = undefined; - this.#reactiveState.status = JobStatus.Idle; - } + clearIfPending(); }); } } @@ -219,13 +214,13 @@ export class Job { */ export function createJob( taskFn: TaskFunction, - options: JobOptions = {} + options: JobOptions = {}, ): Job { const job = new Job(taskFn, options); if (getOwner()) { onCleanup(() => { - job.abort(); + void job.abort(); }); } diff --git a/src/task.ts b/src/task.ts index 48e21eb..d854866 100644 --- a/src/task.ts +++ b/src/task.ts @@ -1,6 +1,6 @@ import { runWithOwner, untrack } from "solid-js"; import { createObject } from "solid-proxies"; -import { work } from "./work"; +import { work } from "./work.ts"; export enum TaskStatus { Idle = "idle", @@ -28,63 +28,63 @@ export class Task implements Promise { return this.#reactiveState.value; } - /** + /** * The current error of the task. */ get error(): unknown { return this.#reactiveState.error; } - /** + /** * Whether the task is currently idle. */ get isIdle(): boolean { return this.status === TaskStatus.Idle; } - /** + /** * Whether the task is currently pending. */ get isPending(): boolean { return this.status === TaskStatus.Pending; } - /** + /** * Whether the task is currently fulfilled. */ get isFulfilled(): boolean { return this.status === TaskStatus.Fulfilled; } - /** + /** * Whether the task is currently rejected. */ get isRejected(): boolean { return this.status === TaskStatus.Rejected; } - /** + /** * Whether the task is currently settled. */ get isSettled(): boolean { - return [TaskStatus.Fulfilled, TaskStatus.Rejected].includes(this.status); + return this.isFulfilled || this.isRejected; } - /** + /** * Whether the task is currently aborted. */ get isAborted(): boolean { return this.status === TaskStatus.Aborted; } - /** + /** * The current status of the task. */ get status(): TaskStatus { return this.#reactiveState.status; } - /** + /** * The signal of the task. Used to abort the task. */ get signal(): AbortSignal { @@ -98,7 +98,9 @@ export class Task implements Promise { #promise?: Promise; #promiseFn: (signal: AbortSignal) => Promise; #abortController = new AbortController(); - #eventTarget = new EventTarget(); + #eventTarget?: EventTarget; + #dispatched = false; + #settledController?: AbortController; #reactiveState = createObject<{ value?: T | null; @@ -113,41 +115,46 @@ export class Task implements Promise { this.#promiseFn = (signal) => runWithOwner(null, () => promiseFn(signal))!; } + // Task is intentionally thenable: it implements the Promise interface. + // oxlint-disable-next-line no-thenable then( - onfulfilled?: - | ((value: T) => TResult1 | PromiseLike) - | null - | undefined, - onrejected?: - | ((reason: any) => TResult2 | PromiseLike) - | null - | undefined + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, ): Promise { return this.#execute().then(onfulfilled, onrejected); } catch( - onrejected?: - | ((reason: any) => TResult | PromiseLike) - | null - | undefined + onrejected?: ((reason: any) => TResult | PromiseLike) | null, ): Promise { return this.#execute().catch(onrejected); } - finally(onfinally?: (() => void) | null | undefined): Promise { + finally(onfinally?: (() => void) | null): Promise { return this.#execute().finally(onfinally); } addEventListener( type: "abort" | "fulfill" | "reject", listener: (event: Event) => void, - options?: boolean | AddEventListenerOptions + options?: boolean | AddEventListenerOptions, ): void { if (typeof options === "boolean") { options = { capture: options }; } + // The task is one-shot: once it has dispatched its terminal event, replay + // that event for late subscribers (promise-like), and ignore listeners for + // events that can no longer happen. + if (this.#dispatched) { + if (this.#terminalEvent() === type) { + queueMicrotask(() => listener(new Event(type))); + } + return; + } + + this.#eventTarget ??= new EventTarget(); + this.#eventTarget.addEventListener(type, listener, { signal: type === "abort" ? undefined : this.signal, once: true, @@ -159,13 +166,34 @@ export class Task implements Promise { removeEventListener( type: "abort" | "fulfill" | "reject", listener: (event: Event) => void, - options?: boolean | EventListenerOptions + options?: boolean | EventListenerOptions, ): void { - this.#eventTarget.removeEventListener(type, listener, options); + this.#eventTarget?.removeEventListener(type, listener, options); } - #dispatchEvent(type: "abort" | "fulfill" | "reject"): void { - this.#eventTarget.dispatchEvent(new Event(type)); + // Dispatches the task's single terminal event (derived from `status`) once. + #dispatch(): void { + if (this.#dispatched) return; + this.#dispatched = true; + + const type = this.#terminalEvent(); + if (type) this.#eventTarget?.dispatchEvent(new Event(type)); + + // Release any external `abortOnSignal` listeners now that the task settled. + this.#settledController?.abort(); + } + + #terminalEvent(): "abort" | "fulfill" | "reject" | undefined { + switch (this.status) { + case TaskStatus.Fulfilled: + return "fulfill"; + case TaskStatus.Rejected: + return "reject"; + case TaskStatus.Aborted: + return "abort"; + default: + return undefined; + } } /** @@ -175,24 +203,48 @@ export class Task implements Promise { return untrack(async () => { if (!this.isIdle && !this.isPending) return; - const error = new TaskAbortError(cancelReason); - this.#abortController.abort(error); - if (this.isIdle) this.#handleFailure(error); + this.#abortController.abort(new TaskAbortError(cancelReason)); + if (this.isIdle) this.#handleFailure(this.signal.reason); - try { - await this.#promise; - } catch (error) { - if (error instanceof TaskAbortError) return; - throw error; - } + // Wait for the task to settle. Once aborted, any rejection is the abort + // itself, so it is safe to swallow — `abort()` resolves, never throws. + await this.#promise?.catch(() => {}); + }); + } + + /** + * Aborts task when the given signal is aborted. + */ + abortOnSignal(signal: AbortSignal): Task { + if (this.isSettled || this.isAborted) return this; + + // Fire-and-forget: swallow any rejection from abort() so it can never + // surface as an unhandled rejection. The failure, if any, is already + // recorded on the task and observable by anyone awaiting it. + const abort = () => void this.abort().catch(() => {}); + + if (signal.aborted) { + abort(); + return this; + } + + // The listener is removed automatically once the task settles (see + // `#dispatch`), so a long-lived external signal never retains a stale + // handler — and the task along with it. + this.#settledController ??= new AbortController(); + signal.addEventListener("abort", abort, { + once: true, + signal: this.#settledController.signal, }); + + return this; } /** * Performs the task. */ perform(): Task { - this.#execute(); + void this.#execute(); return this; } @@ -208,7 +260,7 @@ export class Task implements Promise { const value = await work( this.#abortController.signal, - this.#promiseFn(this.#abortController.signal) + this.#promiseFn(this.#abortController.signal), ); this.#handleSuccess(value); @@ -216,31 +268,34 @@ export class Task implements Promise { return value; } catch (error) { this.#handleFailure(error); - throw error; + // Once aborted, surface the abort reason so awaiters (and `abort()`) see + // a TaskAbortError consistent with the task's status, regardless of what + // the task body actually threw. + throw this.signal.aborted ? this.signal.reason : error; } } #handleFailure(error: this["error"]): void { - this.#reactiveState.error = error; - - if (error instanceof TaskAbortError) { + // A failure that happens once abort was requested is an abort, regardless + // of what the task body threw or rejected with. + if (this.signal.aborted) { + this.#reactiveState.error = this.signal.reason; this.#reactiveState.status = TaskStatus.Aborted; - this.#dispatchEvent("abort"); } else { + this.#reactiveState.error = error; this.#reactiveState.status = TaskStatus.Rejected; - this.#dispatchEvent("reject"); } + + this.#dispatch(); } #handleSuccess(value: this["value"]): void { this.#reactiveState.value = value; this.#reactiveState.status = TaskStatus.Fulfilled; - this.#dispatchEvent("fulfill"); + this.#dispatch(); } } -export function createTask( - promiseFn: (signal: AbortSignal) => Promise -): Task { +export function createTask(promiseFn: (signal: AbortSignal) => Promise): Task { return new Task(promiseFn); } diff --git a/src/work.ts b/src/work.ts index e550581..575d918 100644 --- a/src/work.ts +++ b/src/work.ts @@ -1,28 +1,3 @@ -function abortablePromise(signal: AbortSignal) { - let reject!: (reason?: any) => void; - - const promise = new Promise((_, reject_) => { - reject = reject_; - }); - - const callback = () => { - reject(signal.reason); - }; - - signal.addEventListener("abort", callback, { - once: true, - passive: true, - }); - - return { - promise, - abort(): void { - signal.removeEventListener("abort", callback); - reject(); - }, - }; -} - /** * Run a promise with an abort signal. * @param signal An abort signal. @@ -33,19 +8,26 @@ function abortablePromise(signal: AbortSignal) { * ```ts * const controller = new AbortController(); * const promise = new Promise((resolve) => setTimeout(resolve, 1000)); - * + * * await work(controller.signal, promise); * ``` */ export async function work(signal: AbortSignal, promise: Promise) { signal.throwIfAborted(); - const controlledPromise = abortablePromise(signal); + const { promise: signalPromise, reject } = Promise.withResolvers(); + + const callback = () => { + reject(signal.reason); + }; + + signal.addEventListener("abort", callback, { once: true }); try { - return await Promise.race([controlledPromise.promise, promise]); + return await Promise.race([signalPromise, promise]); } finally { - controlledPromise.abort(); + signal.removeEventListener("abort", callback); + reject(); } } @@ -58,10 +40,20 @@ export async function work(signal: AbortSignal, promise: Promise) { * @example * ```ts * const controller = new AbortController(); - * + * * await timeout(controller.signal, 1000); * ``` */ export async function timeout(signal: AbortSignal, ms: number): Promise { - return work(signal, new Promise((resolve) => setTimeout(resolve, ms))); + return work( + signal, + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + // Clear the pending timer on abort so it does not keep the event loop + // alive for the remainder of `ms` after the work has already settled. + signal.addEventListener("abort", () => clearTimeout(timer), { + once: true, + }); + }), + ); } From 47833d47d7ca31f4a0897a95bc7dbe0a34987c8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Kwa=C5=9Bniak?= Date: Sun, 7 Jun 2026 21:13:04 +0200 Subject: [PATCH 3/5] test: relocate suite to tests/ and expand coverage Move the unit tests to tests/ for the vp test runner and add a work.ts suite. New coverage: abortOnSignal (idle/pending/already-aborted/settled paths, chaining, listener release on every settle path), abort() reclassification and custom reasons, late-subscriber event replay, timeout timer cleanup, and work()/timeout edge cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/{vitest => }/job.test.ts | 34 ++- tests/task.test.ts | 446 +++++++++++++++++++++++++++++++++ tests/vitest/task.test.ts | 164 ------------ tests/work.test.ts | 125 +++++++++ 4 files changed, 600 insertions(+), 169 deletions(-) rename tests/{vitest => }/job.test.ts (78%) create mode 100644 tests/task.test.ts delete mode 100644 tests/vitest/task.test.ts create mode 100644 tests/work.test.ts diff --git a/tests/vitest/job.test.ts b/tests/job.test.ts similarity index 78% rename from tests/vitest/job.test.ts rename to tests/job.test.ts index 9bb7d93..4642015 100644 --- a/tests/vitest/job.test.ts +++ b/tests/job.test.ts @@ -1,6 +1,6 @@ -import { work, timeout } from "../../src/work"; -import { createJob, JobMode } from "../../src/job"; -import { describe, test, expect } from "vitest"; +import { work, timeout } from "../src/work.ts"; +import { createJob, JobMode } from "../src/job.ts"; +import { describe, test, expect } from "vite-plus/test"; import { createRoot, getOwner } from "solid-js"; describe("job", () => { @@ -44,7 +44,7 @@ describe("job", () => { await timeout(signal, 1); return "Hello World"; }, - { mode: JobMode.Restart } + { mode: JobMode.Restart }, ); expect(job.performCount).toBe(0); @@ -114,12 +114,36 @@ describe("job", () => { cleanup(); - await new Promise(process.nextTick); + await new Promise((resolve) => process.nextTick(resolve)); expect(job.isPending).toBe(false); }); }); + test("aborts nested tasks on signal", async () => { + await createRoot(async () => { + const job1 = createJob(async (signal) => { + await work(signal, Promise.resolve()); + return "job1 done"; + }); + + const job2 = createJob(async (signal) => { + await work(signal, job1.perform().abortOnSignal(signal)); + }); + + job2.perform(); + + expect(job1.isPending).toBe(true); + expect(job2.isPending).toBe(true); + + await job2.abort(); + + expect(job2.isPending).toBe(false); + expect(job1.isPending).toBe(false); + expect(job1.lastFulfilled?.value).toBeUndefined(); + }); + }); + test("runs without owner", async () => { await createRoot(async () => { const job = createJob(async () => { diff --git a/tests/task.test.ts b/tests/task.test.ts new file mode 100644 index 0000000..6d2dd82 --- /dev/null +++ b/tests/task.test.ts @@ -0,0 +1,446 @@ +import { createTask, TaskAbortError, TaskStatus } from "../src/task.ts"; +import { describe, test, expect, vi } from "vite-plus/test"; + +describe("Task", () => { + describe("#perform", async () => { + test("fulfilled", async () => { + const task = createTask(() => Promise.resolve("Hello World")); + + expect(task.status).toBe(TaskStatus.Idle); + expect(task.isIdle).toBe(true); + + task.perform(); + + expect(task.status).toBe(TaskStatus.Pending); + expect(task.isPending).toBe(true); + + await task; + + expect(task.status).toBe(TaskStatus.Fulfilled); + expect(task.isFulfilled).toBe(true); + expect(task.isSettled).toBe(true); + expect(task.value).toBe("Hello World"); + }); + + test("rejected", async () => { + const error = new Error("Something went wrong"); + const task = createTask(() => Promise.reject(error)); + + expect(task.status).toBe(TaskStatus.Idle); + expect(task.isIdle).toBe(true); + + task.perform(); + + expect(task.status).toBe(TaskStatus.Pending); + expect(task.isPending).toBe(true); + + await expect(task).rejects.toThrow("Something went wrong"); + + expect(task.status).toBe(TaskStatus.Rejected); + expect(task.isRejected).toBe(true); + expect(task.isSettled).toBe(true); + expect(task.error).toBe(error); + }); + }); + + describe("#abort", async () => { + test("aborting pending task", async () => { + const task = createTask(() => new Promise(() => {})); + + expect(task.status).toBe(TaskStatus.Idle); + + task.perform(); + + expect(task.status).toBe(TaskStatus.Pending); + + await task.abort(); + + expect(task.status).toBe(TaskStatus.Aborted); + expect(task.error).toBeInstanceOf(TaskAbortError); + }); + + test("aborting idle task", async () => { + const task = createTask(() => new Promise(() => {})); + + expect(task.status).toBe(TaskStatus.Idle); + + await task.abort(); + + expect(task.status).toBe(TaskStatus.Aborted); + + await expect(task).rejects.toThrow("The task was aborted."); + + expect(task.status).toBe(TaskStatus.Aborted); + expect(task.error).toBeInstanceOf(TaskAbortError); + }); + + test("uses the custom reason as the abort error message", async () => { + const task = createTask(() => new Promise(() => {})); + task.perform(); + + await task.abort("No longer needed"); + + expect(task.status).toBe(TaskStatus.Aborted); + expect((task.error as Error).message).toBe("No longer needed"); + }); + + test("ends as aborted (not rejected) when the body rejects with its own error", async () => { + const task = createTask( + (signal) => + new Promise((_, reject) => { + // body responds to abort by rejecting with its OWN error + signal.addEventListener("abort", () => reject(new Error("cleanup boom"))); + }), + ); + task.perform(); + + // abort() must resolve, not reject with the body's error. + await expect(task.abort()).resolves.toBeUndefined(); + + expect(task.status).toBe(TaskStatus.Aborted); + expect(task.error).toBeInstanceOf(TaskAbortError); + await expect(task).rejects.toBeInstanceOf(TaskAbortError); + }); + + test("keeps the fulfilled value when the body resolves before a late abort", async () => { + let resolveBody!: (value: string) => void; + const task = createTask(() => new Promise((resolve) => (resolveBody = resolve))); + task.perform(); + + // Body resolves and abort is requested within the same tick. A produced + // result wins: the completed work is delivered rather than discarded. + resolveBody("done"); + await task.abort(); + + expect(task.status).toBe(TaskStatus.Fulfilled); + expect(task.value).toBe("done"); + await expect(task).resolves.toBe("done"); + }); + + test("does not dispatch abort twice when an aborted idle task is awaited", async () => { + const task = createTask(() => new Promise(() => {})); + const listener = vi.fn(); + task.addEventListener("abort", listener); + + await task.abort(); + await expect(task).rejects.toThrow(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + }); + + describe("#abortOnSignal", async () => { + test("aborting pending task", async () => { + const task = createTask(() => new Promise(() => {})); + + expect(task.status).toBe(TaskStatus.Idle); + + task.perform(); + + expect(task.status).toBe(TaskStatus.Pending); + + const controller = new AbortController(); + task.abortOnSignal(controller.signal); + + controller.abort(); + await new Promise((resolve) => process.nextTick(resolve)); + + expect(task.status).toBe(TaskStatus.Aborted); + expect(task.error).toBeInstanceOf(TaskAbortError); + }); + + test("aborting an idle task when the signal fires later", async () => { + const task = createTask(() => new Promise(() => {})); + + const controller = new AbortController(); + task.abortOnSignal(controller.signal); + + expect(task.status).toBe(TaskStatus.Idle); + + controller.abort(); + await new Promise((resolve) => process.nextTick(resolve)); + + expect(task.status).toBe(TaskStatus.Aborted); + expect(task.error).toBeInstanceOf(TaskAbortError); + }); + + test("returns the task for chaining", () => { + const task = createTask(() => new Promise(() => {})); + const controller = new AbortController(); + + expect(task.abortOnSignal(controller.signal)).toBe(task); + }); + + test("does not register a listener when signal is already aborted", () => { + const controller = new AbortController(); + controller.abort(); + + const addSpy = vi.spyOn(controller.signal, "addEventListener"); + + const task = createTask(() => new Promise(() => {})); + task.abortOnSignal(controller.signal); + + expect(addSpy).not.toHaveBeenCalled(); + }); + + test("aborts immediately when the signal is already aborted", () => { + const controller = new AbortController(); + controller.abort(); + + const task = createTask(() => new Promise(() => {})); + task.abortOnSignal(controller.signal); + + expect(task.status).toBe(TaskStatus.Aborted); + expect(task.error).toBeInstanceOf(TaskAbortError); + }); + + test("is a no-op when the task is already settled", async () => { + const task = createTask(() => Promise.resolve("ok")); + task.perform(); + await task; + + const controller = new AbortController(); + const addSpy = vi.spyOn(controller.signal, "addEventListener"); + + expect(task.abortOnSignal(controller.signal)).toBe(task); + expect(addSpy).not.toHaveBeenCalled(); + + controller.abort(); + await new Promise((resolve) => process.nextTick(resolve)); + + expect(task.status).toBe(TaskStatus.Fulfilled); + expect(task.value).toBe("ok"); + }); + + test("stops reacting to the external signal after the task fulfills", async () => { + const controller = new AbortController(); + const task = createTask(() => Promise.resolve("ok")); + task.abortOnSignal(controller.signal); + task.perform(); + + await task; + + const abortSpy = vi.spyOn(task, "abort"); + controller.abort(); + await new Promise((resolve) => process.nextTick(resolve)); + + expect(abortSpy).not.toHaveBeenCalled(); + expect(task.status).toBe(TaskStatus.Fulfilled); + }); + + test("stops reacting to the external signal after the task rejects", async () => { + const controller = new AbortController(); + const task = createTask(() => Promise.reject(new Error("boom"))); + task.abortOnSignal(controller.signal); + task.perform(); + + await expect(task).rejects.toThrow("boom"); + + const abortSpy = vi.spyOn(task, "abort"); + controller.abort(); + await new Promise((resolve) => process.nextTick(resolve)); + + expect(abortSpy).not.toHaveBeenCalled(); + expect(task.status).toBe(TaskStatus.Rejected); + }); + + test("does not leak an unhandled rejection when the task fails as the signal aborts", async () => { + // The body rejects with a NON-abort error in response to the abort. That + // error wins the race inside `work`, so the task's internal promise + // rejects with it (not a TaskAbortError). `abort()` rethrows non-abort + // failures, so the fire-and-forget abort inside `abortOnSignal` would + // surface an unhandled rejection unless it is swallowed. + // + // Note: if this regresses, the rejection escapes as a process-level + // unhandled rejection, which Vitest reports as a suite error (non-zero + // exit) even though the assertions below still pass. We also capture it + // directly as a best-effort second guard. + const rejections: unknown[] = []; + const onRejection = (reason: unknown) => rejections.push(reason); + process.on("unhandledRejection", onRejection); + + try { + const task = createTask( + (signal) => + new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(new Error("cleanup failed"))); + }), + ); + task.perform(); + + const controller = new AbortController(); + task.abortOnSignal(controller.signal); + controller.abort(); + + await task.catch(() => {}); + for (let i = 0; i < 20 && rejections.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + expect(rejections).toEqual([]); + + // A failure that happens once abort was requested is treated as an + // abort, regardless of what the body rejected with. + expect(task.isAborted).toBe(true); + expect(task.error).toBeInstanceOf(TaskAbortError); + } finally { + process.off("unhandledRejection", onRejection); + } + }); + + test("stops reacting to the external signal after the task aborts via another path", async () => { + const controller = new AbortController(); + const task = createTask(() => new Promise(() => {})); + task.abortOnSignal(controller.signal); + task.perform(); + + await task.abort(); + + const abortSpy = vi.spyOn(task, "abort"); + controller.abort(); + await new Promise((resolve) => process.nextTick(resolve)); + + expect(abortSpy).not.toHaveBeenCalled(); + }); + }); + + describe("#addEventListener", async () => { + test("replays the terminal event for listeners added after settle", async () => { + const task = createTask(() => Promise.resolve("Hello World")); + task.perform(); + await task; + + const fulfilled = vi.fn(); + const aborted = vi.fn(); + + // Subscribed only after the task already fulfilled. + task.addEventListener("fulfill", fulfilled); + task.addEventListener("abort", aborted); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + expect(fulfilled).toHaveBeenCalledTimes(1); + expect(aborted).not.toHaveBeenCalled(); + }); + + test("abort", async () => { + const task = createTask(() => new Promise(() => {})); + const listener = vi.fn(); + + task.addEventListener("abort", listener); + + task.perform(); + + await task.abort(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + test("fulfill", async () => { + const task = createTask(() => Promise.resolve("Hello World")); + const listener = vi.fn(); + + task.addEventListener("fulfill", listener); + + task.perform(); + + await task; + + expect(listener).toHaveBeenCalledTimes(1); + }); + + test("reject", async () => { + const error = new Error("Something went wrong"); + const task = createTask(() => Promise.reject(error)); + const listener = vi.fn(); + + task.addEventListener("reject", listener); + + task.perform(); + + await expect(task).rejects.toThrow("Something went wrong"); + + expect(listener).toHaveBeenCalledTimes(1); + }); + }); + + describe("#removeEventListener", async () => { + test("does not throw when no listener was ever added", () => { + const task = createTask(() => new Promise(() => {})); + const listener = () => {}; + + expect(() => task.removeEventListener("abort", listener)).not.toThrow(); + }); + + test("abort", async () => { + const task = createTask(() => new Promise(() => {})); + const listener = vi.fn(); + + task.addEventListener("abort", listener); + task.removeEventListener("abort", listener); + + task.perform(); + + await task.abort(); + + expect(listener).not.toHaveBeenCalled(); + }); + + test("fulfill", async () => { + const task = createTask(() => Promise.resolve("Hello World")); + const listener = vi.fn(); + + task.addEventListener("fulfill", listener); + task.removeEventListener("fulfill", listener); + + task.perform(); + + await task; + + expect(listener).not.toHaveBeenCalled(); + }); + + test("reject", async () => { + const error = new Error("Something went wrong"); + const task = createTask(() => Promise.reject(error)); + const listener = vi.fn(); + + task.addEventListener("reject", listener); + task.removeEventListener("reject", listener); + + task.perform(); + + await expect(task).rejects.toThrow("Something went wrong"); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + test("signal lets the task body clean up side effects on abort", async () => { + const listener = vi.fn(); + const task = createTask( + (signal) => + new Promise((_, reject) => { + const interval = setInterval(listener, 1); + signal.addEventListener("abort", () => { + clearInterval(interval); + reject(signal.reason); + }); + }), + ); + + task.perform(); + + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(listener).toHaveBeenCalled(); + + await task.abort(); + const callsAtAbort = listener.mock.calls.length; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(listener).toHaveBeenCalledTimes(callsAtAbort); + expect(task.status).toBe(TaskStatus.Aborted); + expect(task.error).toBeInstanceOf(TaskAbortError); + }); +}); diff --git a/tests/vitest/task.test.ts b/tests/vitest/task.test.ts deleted file mode 100644 index 2273e35..0000000 --- a/tests/vitest/task.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { createTask, TaskAbortError, TaskStatus } from "../../src/task"; -import { describe, test, expect, vi } from "vitest"; - -describe("Task", () => { - describe("#perform", async () => { - test("fulfilled", async () => { - const task = createTask(() => Promise.resolve("Hello World")); - - expect(task.status).toBe(TaskStatus.Idle); - expect(task.isIdle).toBe(true); - - task.perform(); - - expect(task.status).toBe(TaskStatus.Pending); - expect(task.isPending).toBe(true); - - await task; - - expect(task.status).toBe(TaskStatus.Fulfilled); - expect(task.isFulfilled).toBe(true); - expect(task.isSettled).toBe(true); - expect(task.value).toBe("Hello World"); - }); - - test("rejected", async () => { - const error = new Error("Something went wrong"); - const task = createTask(() => Promise.reject(error)); - - expect(task.status).toBe(TaskStatus.Idle); - expect(task.isIdle).toBe(true); - - task.perform(); - - expect(task.status).toBe(TaskStatus.Pending); - expect(task.isPending).toBe(true); - - await expect(task).rejects.toThrow("Something went wrong"); - - expect(task.status).toBe(TaskStatus.Rejected); - expect(task.isRejected).toBe(true); - expect(task.isSettled).toBe(true); - expect(task.error).toBe(error); - }); - }); - - describe("#abort", async () => { - test("aborting pending task", async () => { - const task = createTask(() => new Promise(() => {})); - - expect(task.status).toBe(TaskStatus.Idle); - - task.perform(); - - expect(task.status).toBe(TaskStatus.Pending); - - await task.abort(); - - expect(task.status).toBe(TaskStatus.Aborted); - expect(task.error).toBeInstanceOf(TaskAbortError); - }); - - test("aborting idle task", async () => { - const task = createTask(() => new Promise(() => {})); - - expect(task.status).toBe(TaskStatus.Idle); - - await task.abort(); - - expect(task.status).toBe(TaskStatus.Aborted); - - await expect(task).rejects.toThrow("The task was aborted."); - - expect(task.status).toBe(TaskStatus.Aborted); - expect(task.error).toBeInstanceOf(TaskAbortError); - }); - }); - - describe("#addEventListener", async () => { - test("abort", async () => { - const task = createTask(() => new Promise(() => {})); - const listener = vi.fn(); - - task.addEventListener("abort", listener); - - task.perform(); - - await task.abort(); - - expect(listener).toHaveBeenCalledTimes(1); - }); - - test("fulfill", async () => { - const task = createTask(() => Promise.resolve("Hello World")); - const listener = vi.fn(); - - task.addEventListener("fulfill", listener); - - task.perform(); - - await task; - - expect(listener).toHaveBeenCalledTimes(1); - }); - - test("reject", async () => { - const error = new Error("Something went wrong"); - const task = createTask(() => Promise.reject(error)); - const listener = vi.fn(); - - task.addEventListener("reject", listener); - - task.perform(); - - await expect(task).rejects.toThrow("Something went wrong"); - - expect(listener).toHaveBeenCalledTimes(1); - }); - }); - - describe("#removeEventListener", async () => { - test("abort", async () => { - const task = createTask(() => new Promise(() => {})); - const listener = vi.fn(); - - task.addEventListener("abort", listener); - task.removeEventListener("abort", listener); - - task.perform(); - - await task.abort(); - - expect(listener).not.toHaveBeenCalled(); - }); - - test("fulfill", async () => { - const task = createTask(() => Promise.resolve("Hello World")); - const listener = vi.fn(); - - task.addEventListener("fulfill", listener); - task.removeEventListener("fulfill", listener); - - task.perform(); - - await task; - - expect(listener).not.toHaveBeenCalled(); - }); - - test("reject", async () => { - const error = new Error("Something went wrong"); - const task = createTask(() => Promise.reject(error)); - const listener = vi.fn(); - - task.addEventListener("reject", listener); - task.removeEventListener("reject", listener); - - task.perform(); - - await expect(task).rejects.toThrow("Something went wrong"); - - expect(listener).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/tests/work.test.ts b/tests/work.test.ts new file mode 100644 index 0000000..5193cc7 --- /dev/null +++ b/tests/work.test.ts @@ -0,0 +1,125 @@ +import { work, timeout } from "../src/work.ts"; +import { describe, test, expect, vi } from "vite-plus/test"; + +describe("work", () => { + test("resolves with the promise value", async () => { + const controller = new AbortController(); + + await expect(work(controller.signal, Promise.resolve("Hello World"))).resolves.toBe( + "Hello World", + ); + }); + + test("rejects with the promise error", async () => { + const controller = new AbortController(); + const error = new Error("Something went wrong"); + + await expect(work(controller.signal, Promise.reject(error))).rejects.toBe(error); + }); + + test("rejects with the signal reason when aborted mid-flight", async () => { + const controller = new AbortController(); + const reason = new Error("aborted mid-flight"); + + const promise = work(controller.signal, new Promise(() => {})); + controller.abort(reason); + + await expect(promise).rejects.toBe(reason); + }); + + test("throws immediately when the signal is already aborted", async () => { + const controller = new AbortController(); + const reason = new Error("already aborted"); + controller.abort(reason); + + await expect(work(controller.signal, Promise.resolve("ignored"))).rejects.toBe(reason); + }); + + test("removes its abort listener after the promise settles", async () => { + const controller = new AbortController(); + const removeSpy = vi.spyOn(controller.signal, "removeEventListener"); + + await work(controller.signal, Promise.resolve("done")); + + expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function)); + }); + + test("does not leak an unhandled rejection when the promise wins the race", async () => { + // The `finally` block calls reject() on the internal signal promise after + // the main promise already won. Because Promise.race keeps a reaction + // attached to that promise, the rejection is handled — no unhandled + // rejection should ever surface. + const rejections: unknown[] = []; + const onRejection = (reason: unknown) => rejections.push(reason); + process.on("unhandledRejection", onRejection); + + try { + const controller = new AbortController(); + + for (let i = 0; i < 50; i++) { + await expect(work(controller.signal, Promise.resolve(i))).resolves.toBe(i); + } + + // Give the event loop time to flag any still-unhandled rejection. + for (let i = 0; i < 10 && rejections.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + + expect(rejections).toEqual([]); + } finally { + process.off("unhandledRejection", onRejection); + } + }); + + test("removes its abort listener after the promise rejects", async () => { + const controller = new AbortController(); + const removeSpy = vi.spyOn(controller.signal, "removeEventListener"); + + await expect(work(controller.signal, Promise.reject(new Error("boom")))).rejects.toThrow( + "boom", + ); + + expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function)); + }); +}); + +describe("timeout", () => { + test("resolves after the given delay", async () => { + const controller = new AbortController(); + + await expect(timeout(controller.signal, 1)).resolves.toBeUndefined(); + }); + + test("rejects with the signal reason when aborted before elapsing", async () => { + const controller = new AbortController(); + const reason = new Error("cancelled timeout"); + + const promise = timeout(controller.signal, 1000); + controller.abort(reason); + + await expect(promise).rejects.toBe(reason); + }); + + test("throws immediately when the signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(timeout(controller.signal, 1000)).rejects.toThrow(); + }); + + test("clears its pending timer when aborted", async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const promise = timeout(controller.signal, 60_000).catch(() => {}); + + controller.abort(); + await promise; + + // The 60s timer must be cleared, not left pending to hold the event loop. + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); +}); From e7453c28d29a191e97c64a9569f3a7887c3db763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Kwa=C5=9Bniak?= Date: Sun, 7 Jun 2026 21:13:19 +0200 Subject: [PATCH 4/5] docs: document abortOnSignal and refresh API docs Add an "abortOnSignal" section to the README (usage, chaining, and the settle/already-aborted/no-leak behaviour) and update the task and job API docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 79 +++++++++++++++++++++++++++++++++------------------- docs/job.md | 2 +- docs/task.md | 4 +-- 3 files changed, 54 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index fbe7995..65ac20b 100644 --- a/README.md +++ b/README.md @@ -37,15 +37,16 @@ This library is especially useful in real user interfaces where the same action ## Scope and abstraction level -**It is crucial to understand that Solid Tasks is a low-level primitive, not a high-level framework.** +**It is crucial to understand that Solid Tasks is a low-level primitive, not a high-level framework.** -This library is designed to deal strictly with the **state machine and UI flow of Promises** (loading flags, cancellation, fulfillment, rejection) and localized concurrency (dropping or restarting overlapping requests). +This library is designed to deal strictly with the **state machine and UI flow of Promises** (loading flags, cancellation, fulfillment, rejection) and localized concurrency (dropping or restarting overlapping requests). -While it *can* be used directly in your application code, **it is not a replacement for high-level data fetching libraries like TanStack Query or Solid's native `createResource`.** +While it _can_ be used directly in your application code, **it is not a replacement for high-level data fetching libraries like TanStack Query or Solid's native `createResource`.** If you attempt to use Solid Tasks as your primary global data-fetching and caching layer, you will find that it is not optimized for that Developer Experience (DX) and will require significant boilerplate. It does not handle global cache invalidation, background refetching, or pagination out of the box. Instead, Solid Tasks is the kind of primitive you use to **build** more complex solutions. It shines when used as: + - The underlying engine for advanced, bespoke event-handling pipelines. - A building block for custom data-loading wrappers. - A tool to manage complex, multi-step UI mutations (like chained save/upload operations) where you need fine-grained control over cancellation and race conditions. @@ -227,7 +228,7 @@ import { A `Task` wraps one async function of the form: ```ts -(signal: AbortSignal) => Promise +(signal: AbortSignal) => Promise; ``` The task owns an `AbortController` and exposes the corresponding signal to the function. That means your async code can participate in cancellation naturally. @@ -333,6 +334,32 @@ Cancellation is not treated as a mysterious side effect. It is a first-class sta This is academically important because cancellation is not merely “failure.” It is a different category of outcome. A cancelled request does not necessarily mean the system malfunctioned; it may mean the system behaved correctly by discarding obsolete work. +### Linking a task to an external signal + +A task can also be told to abort itself whenever an external `AbortSignal` aborts. This is useful when a task is performed inside another abortable operation and should share its lifetime: + +```ts +const childTask = createTask(loadDetails).perform(); + +// When `signal` aborts, `childTask` aborts too. +childTask.abortOnSignal(signal); +``` + +`abortOnSignal` returns the same task, so it composes naturally with `perform`: + +```ts +const job = createJob(async (signal) => { + const details = await otherJob.perform().abortOnSignal(signal); + return render(details); +}); +``` + +Behavioural notes: + +- If the signal is **already aborted**, the task is aborted immediately and no listener is attached. +- If the task is already **settled** (fulfilled, rejected, or aborted), the call is a no-op and returns the task unchanged. +- Once the task settles by any path, the listener on the external signal is removed automatically, so there is no leak even when the signal outlives the task. + ### Task events Tasks also support lifecycle events: @@ -362,7 +389,7 @@ This event model is especially useful when jobs coordinate task histories. A `Job` wraps a repeatable async function: ```ts -(signal: AbortSignal, ...args: Args) => Promise +(signal: AbortSignal, ...args: Args) => Promise; ``` Each call to `job.perform(...args)` creates a new `Task`. @@ -382,7 +409,7 @@ const searchJob = createJob( }); return response.json(); }, - { mode: JobMode.Restart } + { mode: JobMode.Restart }, ); ``` @@ -605,7 +632,7 @@ Those intents map naturally to job policies. ## Why data loading needs more than fetch -Data loading in UI code is deceptively tricky. As mentioned in the Scope section, while you *can* build data loaders with Jobs, be aware that you are dealing with raw primitives. For global cache-managed data, tools like TanStack Query are better suited. However, for localized, imperative data fetching, Jobs are incredibly powerful. +Data loading in UI code is deceptively tricky. As mentioned in the Scope section, while you _can_ build data loaders with Jobs, be aware that you are dealing with raw primitives. For global cache-managed data, tools like TanStack Query are better suited. However, for localized, imperative data fetching, Jobs are incredibly powerful. The surface problem seems simple: @@ -636,7 +663,7 @@ const loadProductJob = createJob( const response = await fetch(`/api/products/${id}`, { signal }); return response.json(); }, - { mode: JobMode.Restart } + { mode: JobMode.Restart }, ); ``` @@ -712,7 +739,7 @@ const saveProfileJob = createJob( return response.json(); }, - { mode: JobMode.Drop } + { mode: JobMode.Drop }, ); ``` @@ -874,7 +901,7 @@ const searchJob = createJob( return response.json(); }, - { mode: JobMode.Restart } + { mode: JobMode.Restart }, ); ``` @@ -910,7 +937,7 @@ const searchJob = createJob( if (!response.ok) throw new Error("Search failed"); return response.json(); }, - { mode: JobMode.Restart } + { mode: JobMode.Restart }, ); ``` @@ -961,16 +988,12 @@ const saveJob = createJob( if (!response.ok) throw new Error("Save failed"); return response.json(); }, - { mode: JobMode.Drop } + { mode: JobMode.Drop }, ); ``` ```tsx - ``` @@ -992,7 +1015,7 @@ const loadReportJob = createJob( if (!response.ok) throw new Error("Load failed"); return response.json(); }, - { mode: JobMode.Restart } + { mode: JobMode.Restart }, ); createEffect(() => { @@ -1029,7 +1052,7 @@ const loadWithRetryJob = createJob( throw lastError; }, - { mode: JobMode.Restart } + { mode: JobMode.Restart }, ); ``` @@ -1256,7 +1279,7 @@ const job = createJob( }, { mode: JobMode.Drop, - } + }, ); ``` @@ -1284,8 +1307,8 @@ job.abort(reason?); ## `JobMode` ```ts -JobMode.Drop -JobMode.Restart +JobMode.Drop; +JobMode.Restart; ``` ### `Drop` @@ -1303,11 +1326,11 @@ JobMode.Restart ## `TaskStatus` ```ts -TaskStatus.Idle -TaskStatus.Pending -TaskStatus.Fulfilled -TaskStatus.Rejected -TaskStatus.Aborted +TaskStatus.Idle; +TaskStatus.Pending; +TaskStatus.Fulfilled; +TaskStatus.Rejected; +TaskStatus.Aborted; ``` --- @@ -1375,4 +1398,4 @@ Its key contribution is conceptual clarity: If a codebase treats asynchronous events as raw callbacks, concurrency bugs eventually become inevitable. If it treats them as jobs, the behavior becomes explicit, testable, and understandable. -That is why this library is most powerful not as a helper, but as an architectural pattern for building complex interactive systems. \ No newline at end of file +That is why this library is most powerful not as a helper, but as an architectural pattern for building complex interactive systems. diff --git a/docs/job.md b/docs/job.md index 64ccc4f..643d556 100644 --- a/docs/job.md +++ b/docs/job.md @@ -1,6 +1,6 @@ # Job -A job is a +A job is a ## API diff --git a/docs/task.md b/docs/task.md index c0000bd..4f26419 100644 --- a/docs/task.md +++ b/docs/task.md @@ -7,10 +7,10 @@ A Task represents an asynchronous computation. It is a wrapper around a Promise ### Construction ```ts -import { createTask } from 'solid-tasks'; +import { createTask } from "solid-tasks"; const task = createTask(async (signal) => { - const response = await fetch('...', { signal }) + const response = await fetch("...", { signal }); return response.json(); }); ``` From 860c6d6f2613938b76c327caf2435161e2082374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Kwa=C5=9Bniak?= Date: Sun, 7 Jun 2026 21:20:04 +0200 Subject: [PATCH 5/5] docs: add a "Why you need this" guide and decision table Lead with the practical case for the library instead of a caveat: frame every async user action as an operation-with-a-policy, show the raw async-handler footguns vs. the Job equivalent, and add a Drop/Restart/Task decision table so developers can pick the right primitive at a glance. Also document abortOnSignal in the README API reference and docs/task.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/task.md | 1 + 2 files changed, 72 insertions(+) diff --git a/README.md b/README.md index 65ac20b..8422aa7 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This library is especially useful in real user interfaces where the same action ## Table of contents +- [Why you need this](#why-you-need-this) - [Scope and abstraction level](#scope-and-abstraction-level) - [Why this library exists](#why-this-library-exists) - [What problem it solves](#what-problem-it-solves) @@ -35,6 +36,67 @@ This library is especially useful in real user interfaces where the same action --- +## Why you need this + +Most async bugs in a UI trace back to one mistake: treating a user action as _"call this async function"_ when it is really **an operation with a concurrency policy**. + +Take a single **Save** button. The moment it touches the network, you implicitly owe answers to: + +- What if it's clicked twice before the first request finishes? +- What if the user navigates away mid-request? +- What if a newer action starts before the older one resolves? +- What if a stale response arrives _after_ newer state already exists? + +These are not edge cases — they are the normal behavior of interactive software. Every meaningful click, keystroke, or navigation is one of these operations. With raw promises you answer the questions by hand, over and over, with scattered signals, booleans, abort controllers, and guards: + +```tsx +// The hidden cost of "just an async handler" +const [isSaving, setIsSaving] = createSignal(false); +const [error, setError] = createSignal(null); + +const save = async () => { + setIsSaving(true); + setError(null); + try { + await fetch("/api/save", { method: "POST" }); // not cancellable + } catch (err) { + setError(err as Error); // a cancelled request looks like a failure + } finally { + setIsSaving(false); // nothing stops a second click from overlapping + } +}; +``` + +A **Job** turns that same action into one explicit, abortable, stateful object — and the concurrency policy becomes part of the definition, not an afterthought: + +```tsx +const saveJob = createJob( + async (signal) => { + await fetch("/api/save", { method: "POST", signal }); // cancellable + }, + { mode: JobMode.Drop }, // duplicate clicks are refused, structurally +); + +// pending / error / success all derive from a single source of truth +; +``` + +That is the entire pitch: **model every meaningful async user action or event as a `Task` or `Job`, and concurrency stops being accidental.** Loading flags, cancellation, race protection, and "remember the last good value" all come for free, in one place, every time. + +### Which one do I reach for? + +| You have… | Use | Because | +| -------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------- | +| A repeatable action where the **first** call should win — Save, Pay, Delete, Submit, Upload | `createJob(fn, { mode: JobMode.Drop })` | duplicate triggers are refused while one is in flight | +| A repeatable action where the **latest** call should win — search, autocomplete, filter, route/tab loads | `createJob(fn, { mode: JobMode.Restart })` | the previous run is aborted so stale results can't land | +| A **single** abortable run, or a building block inside other logic | `createTask(fn)` | one execution with lifecycle + cancellation, no policy | + +New here? Read [Core mental model](#core-mental-model) for the `Task` vs `Job` split, then [Why UI events should be Jobs](#why-ui-events-should-be-jobs) for the full argument. + +--- + ## Scope and abstraction level **It is crucial to understand that Solid Tasks is a low-level primitive, not a high-level framework.** @@ -1255,6 +1317,7 @@ const task = createTask(async (signal) => { ```ts task.perform(); task.abort(reason?); +task.abortOnSignal(signal); // abort this task when `signal` aborts; returns the task task.then(...); task.catch(...); task.finally(...); @@ -1262,6 +1325,14 @@ task.addEventListener(type, listener, options?); task.removeEventListener(type, listener, options?); ``` +`abortOnSignal` links a task to an external `AbortSignal` and returns the task, so it composes with `perform`: + +```ts +const child = otherJob.perform().abortOnSignal(signal); +``` + +When `signal` aborts, the task aborts too. If `signal` is already aborted the task aborts immediately; if the task is already settled the call is a no-op; and the listener is released automatically once the task settles, so a long-lived signal never leaks handlers. See [Linking a task to an external signal](#linking-a-task-to-an-external-signal). + ### Task events - `"abort"` diff --git a/docs/task.md b/docs/task.md index 4f26419..46740c3 100644 --- a/docs/task.md +++ b/docs/task.md @@ -32,6 +32,7 @@ const task = createTask(async (signal) => { - `perform()` - Starts execution of the Task. - `abort(cancelReason)` - Aborts the Task with an optional cancel reason. +- `abortOnSignal(signal)` - Aborts the Task when the given `AbortSignal` aborts; returns the Task. The listener is released automatically once the Task settles. - `then(onFulfilled, onRejected)` - Registers callbacks to be called when the Task is fulfilled or rejected. - `catch(onRejected)` - Registers a callback to be called when the Task is rejected. - `finally(onFinally)` - Registers a callback to be called when the Task is settled.