Skip to content

Tasks#10

Merged
adebola-io merged 10 commits intomainfrom
tasks
Feb 8, 2026
Merged

Tasks#10
adebola-io merged 10 commits intomainfrom
tasks

Conversation

@adebola-io
Copy link
Owner

@adebola-io adebola-io commented Feb 6, 2026

Introduces AsyncTaskCell, a new cell type for one-time, user-triggered asynchronous operations. Unlike Cell.derivedAsync() which automatically re-computes when dependencies change, Cell.task() creates a cell that only executes when explicitly invoked via runWith(input).

Motivation

Cell.derivedAsync() is designed for declarative, reactive data fetching — it watches dependencies and re-runs automatically. However, many async operations are inherently imperative:

  • Form submissions
  • One-time API calls (e.g., login, upload, delete)
  • User-triggered actions

These operations should not auto-execute and need explicit invocation with dynamic input. Previously, there was no first-class way to model these within the cell system while still benefiting from built-in pending/error state tracking and integration with Cell.createComposite.

Changes

Refactored class hierarchy

Extracted a shared AsyncCell base class from AsyncDerivedCell to enable code reuse:

Cell → DerivedCell → AsyncCell → AsyncDerivedCell
                               → AsyncTaskCell

AsyncCell contains the common async machinery: pending/error state management, abort controller lifecycle, upstream promise tracking, and the core computation/cancellation logic.

New AsyncTaskCell (Cell.task())

const submitTask = Cell.task(async (formData, signal) => {
  const response = await fetch('/api/submit', {
    method: 'POST',
    body: JSON.stringify(formData),
    signal,
  });
  return response.json();
});

// Explicitly trigger execution
const result = await submitTask.runWith({ name: 'John' });

// Reactive state tracking works out of the box
submitTask.pending.listen((loading) => { /* ... */ });
submitTask.error.listen((err) => { /* ... */ });

Key behaviors:

  • Not pending until first runWith() call — unlike derivedAsync, which starts pending immediately.
  • Each runWith() call aborts the previous in-flight execution via AbortSignal.
  • Integrates with Cell.createComposite for grouped pending/error tracking across multiple tasks.
  • Works as a dependency in Cell.derivedAsync — derived cells recompute when the task completes.

Public abort() method on AsyncCell

Both AsyncDerivedCell and AsyncTaskCell now expose an abort() method for external cancellation of in-flight computations.

Broadened instanceof checks

Updated LocalContext disposal and Cell.createComposite to check instanceof AsyncCell instead of instanceof AsyncDerivedCell, so both derived and task cells are handled correctly for cleanup and composite state aggregation.

Tests

Added comprehensive test coverage for Cell.task():

  • Basic execution with various input types (number, string, object, array)
  • Pending and error state lifecycle
  • Error recovery across executions
  • Concurrent call handling and abort semantics
  • AbortSignal passthrough
  • No auto-execution on creation
  • Integration with Cell.derived for dependent computations
  • Integration with Cell.createComposite (single task, multiple tasks, mixed with sync cells)
  • Integration with Cell.derivedAsync (dependency tracking, recomputation on re-execution, error propagation, multiple task dependencies)

Summary by CodeRabbit

  • New Features
    • Added Cell.task and a new async task cell for one-shot, on-demand async operations; AsyncDerivedCell gains explicit revalidate.
  • Behavior Changes
    • Async cells, derived values and composites now consistently propagate loading/error states, support AbortSignal-based cancellation, and are disposed reliably.
  • Tests
    • Large test suite covering tasks, concurrency, cancellation, derived interactions and composites.
  • Documentation
    • Expanded README with examples, API guidance and patterns for async tasks and composites.

Refactor AsyncDerivedCell into a base AsyncCell class to support the
new AsyncTaskCell. Unlike derived cells, tasks are manually triggered
via runWith(input) while providing reactive pending and error states.
Update runWith to trigger a new execution for every call instead of
sharing the promise from an active execution.
@coderabbitai
Copy link

coderabbitai bot commented Feb 6, 2026

📝 Walkthrough

Walkthrough

Adds a new AsyncTaskCell API (accessible via Cell.task) and integrates a generalized AsyncCell/AsyncDerivedCell model with AbortSignal-based cancellation, updated composite/pending/error aggregation, expanded disposal via LocalContext.destroy, extensive tests for task/derived behavior, and a rewritten README.

Changes

Cohort / File(s) Summary
Core library
library/classes.js
Added AsyncTaskCell and Cell.task; generalized AsyncCell/AsyncDerivedCell usage with revalidate(), AbortController/AbortSignal wiring, updated pending/error propagation, composite/loading handling, and disposal via DisposeAsyncCell in LocalContext.destroy.
Tests
tests/index.test.js
Large suite of tests for Cell.task, AsyncTaskCell semantics, concurrency/abort behavior, integration with derived/derivedAsync and createComposite/composite constructs, pending/error propagation, and edge cases (peek, cancellation, SWR-like flows).
Documentation
README.md
Comprehensive rewrite: expanded Quick Start, API reference (including task/async primitives), TypeScript guidance, examples for synchronous and asynchronous flows, context/disposal guidance, and usage patterns for composite and derived async cells.

Sequence Diagram(s)

sequenceDiagram
  participant Caller as Caller
  participant TaskCell as AsyncTaskCell
  participant TaskFn as Task Function
  participant Composite as Composite / Derived
  participant Context as LocalContext

  Caller->>TaskCell: call runWith(input)
  activate TaskCell
  TaskCell->>TaskCell: create AbortController + set pending=true
  TaskCell->>TaskFn: invoke fn(input, signal)
  activate TaskFn
  TaskFn-->>TaskCell: resolves/rejects (may observe signal)
  deactivate TaskFn
  alt resolved
    TaskCell->>TaskCell: set value, clear error, pending=false
    TaskCell->>Composite: propagate value/pending change
  else rejected/aborted
    TaskCell->>TaskCell: set error or keep previous value, pending=false
    TaskCell->>Composite: propagate error/pending change
  end
  deactivate TaskCell

  Note over Context, TaskCell: Context.destroy -> DisposeAsyncCell -> cancels/disposes TaskCell
  Context->>TaskCell: dispose / abort in-flight runs
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • AbortControllers #5 — similar changes adding AbortController/AbortSignal cancellation and modified async cell run semantics (data/null returns, aborting prior runs).
  • Async Derived Cells #8 — related edits to async cell primitives (AsyncDerivedCell/AsyncCell) and composite/derived async wiring.
  • Local Contexts. #6 — changes around LocalContext lifecycle and LocalContext.destroy disposal behavior, related to expanded cleanup of async cells.

"I hopped through code with nimble paws,
Tasks that start and stop without a pause.
Abort the old, let the new run free,
Composites cheer, async cells agree.
— 🐇"

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Tasks' is vague and does not clearly summarize the main changes. While it relates to the addition of AsyncTaskCell, it lacks specificity about what tasks are being introduced or the scope of the refactoring involved. Clarify the title to be more specific, such as 'Introduce AsyncTaskCell for user-triggered async operations' or 'Add Cell.task() API with AsyncCell base class refactoring' to better convey the primary changes.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch tasks

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@library/classes.js`:
- Around line 1339-1356: Update the AsyncTaskCell JSDoc to remove the stale
claim about deduplication: delete or reword the line "Caches concurrent calls
with the same input to avoid duplicate executions" and instead state that
AsyncTaskCell does NOT deduplicate concurrent runWith(input) calls; mention that
concurrent calls are independent (no caching) and that cancellation must be
handled via AbortSignal. Ensure you update references to AsyncTaskCell, runWith,
pending, and error in the comment so the docs accurately reflect current
behavior.
- Around line 1418-1426: The code uses currentInput = null as a sentinel so
computedFn skips calling fn when currentInput === null, which incorrectly drops
legitimate inputs like runWith(null); change the sentinel to an explicit boolean
flag (e.g., hasInput) alongside a separate input variable (currentInput) so
computedFn checks hasInput rather than currentInput === null, and ensure runWith
sets both currentInput and hasInput and computedFn captures currentInput into
capturedInput before calling fn(this._signal) via fn(capturedInput,
this._signal); update any JSDoc/type comments accordingly to reflect that
currentInput may be null but hasInput signals presence.
🧹 Nitpick comments (1)
tests/index.test.js (1)

1807-1857: Remove leftover console.log debug statements from this test.

Lines 1811, 1817, 1823, and 1833 have console.log calls ('\nderiving a', 'deriving b', 'deriving c', 'deriving d') that appear to be debugging artifacts from development. They add noise to test output.

Suggested cleanup
       const a = Cell.derivedAsync(async (get) => {
-        console.log('\nderiving a');
         await new Promise((r) => setTimeout(r, 10));
         return get(source);
       });

       const b = Cell.derivedAsync(async (get) => {
-        console.log('deriving b');
         const val = await get(a);
         await new Promise((r) => setTimeout(r, 15));
         return val * 10;
       });

       const c = Cell.derivedAsync(async (get) => {
-        console.log('deriving c');
         const val = await get(a);
         await new Promise((r) => setTimeout(r, 5));
         return val + 100;
       });

       const d = Cell.derivedAsync(async (get) => {
         const bVal = await get(b);
         const cVal = await get(c);
-        console.log('deriving d');
         await new Promise((r) => setTimeout(r, 10));
         return bVal + cVal;
       });

Use a flag to track execution status, allowing null as a valid input.
Clarify that concurrent calls are not deduplicated or cached.
@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 8, 2026

Open in StackBlitz

npm i https://pkg.pr.new/adebola-io/cells/@adbl/cells@10

commit: e1b1b9d

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@library/classes.js`:
- Around line 1466-1492: The first-execution .then() handler in runWith may act
on a stale run if a subsequent runWith replaced this.wvalue; to fix, capture a
local run identity or the returned promise (e.g., const runId =
this.currentRunId or const myValue = value) inside runWith and, at the start of
the .then() handler, bail out if the run is stale (for example check that
this.wvalue === myValue or this.currentRunId === runId); only call
this.update(), iterate this.derivations and push children to UPDATE_BUFFER and
call triggerUpdate() when the captured identity still matches the cell, keeping
the existing AsyncDerivedCell and IsScheduled checks intact.

Comment on lines +1466 to +1492
this.runWith = async (input) => {
const isFirstExecution = !hasExecuted;
this.abort();
currentInput = input;
hasInput = true;
const value = this.computedFn();
hasExecuted = true;

// For the first execution, we need to manually trigger an update
// since AsyncCell skips update() for the initial state.
// We also need to schedule AsyncDerivedCell children for recomputation.
if (isFirstExecution) {
value.then(() => {
this.update();
// Schedule AsyncDerivedCell children for recomputation
for (const child of this.derivations) {
if (child instanceof AsyncDerivedCell && !child[IsScheduled]) {
UPDATE_BUFFER.push(child);
child[IsScheduled] = true;
}
}
if (!IS_UPDATING) triggerUpdate();
});
}

return value;
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

First-execution .then() handler lacks a staleness guard.

If a second runWith is called before the first completes, the first execution's .then() at line 1478 still fires (because the tripwire resolves the aborted promise). It calls this.update() and schedules children even though it belongs to a superseded run. In practice, this.wvalue will already point to the second execution's promise and !child[IsScheduled] prevents double-scheduling, so this is unlikely to cause visible bugs—but it's fragile and inconsistent with the staleness guards used elsewhere in AsyncCell (e.g., currentRunId !== runId checks in .finally()).

Consider capturing a local snapshot of the run state to skip the handler when stale:

Proposed fix
     this.runWith = async (input) => {
       const isFirstExecution = !hasExecuted;
       this.abort();
       currentInput = input;
       hasInput = true;
       const value = this.computedFn();
       hasExecuted = true;
+      // Capture a reference to detect if a newer runWith supersedes this one.
+      const capturedValue = this.wvalue;

       if (isFirstExecution) {
         value.then(() => {
+          // If another runWith was called, this execution is stale.
+          if (this.wvalue !== capturedValue) return;
           this.update();
           for (const child of this.derivations) {
             if (child instanceof AsyncDerivedCell && !child[IsScheduled]) {
               UPDATE_BUFFER.push(child);
               child[IsScheduled] = true;
             }
           }
           if (!IS_UPDATING) triggerUpdate();
         });
       }

       return value;
     };
🤖 Prompt for AI Agents
In `@library/classes.js` around lines 1466 - 1492, The first-execution .then()
handler in runWith may act on a stale run if a subsequent runWith replaced
this.wvalue; to fix, capture a local run identity or the returned promise (e.g.,
const runId = this.currentRunId or const myValue = value) inside runWith and, at
the start of the .then() handler, bail out if the run is stale (for example
check that this.wvalue === myValue or this.currentRunId === runId); only call
this.update(), iterate this.derivations and push children to UPDATE_BUFFER and
call triggerUpdate() when the captured identity still matches the cell, keeping
the existing AsyncDerivedCell and IsScheduled checks intact.

Renames `Cell.createComposite` to `Cell.composite` and adds a `loaded`
property to track initial load completion. Also refines `AsyncCell`
types to return `Promise<T>` instead of `Promise<T | null>`.
Rewrite the README with a quick start guide, API reference, and
advanced usage patterns. Also remove the `name` debugging option from
effects and reformat the test suite for consistent indentation.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@library/classes.js`:
- Around line 1009-1028: The JSDoc for abort() contradicts the PR summary: if
AsyncCell is intended to expose abort() publicly, remove the `@protected` tag from
the abort() JSDoc (and ensure the description still returns {void}) so the
method is documented as public; if abort() should stay protected, update the PR
description to not claim a public abort() API—refer to the abort() method on
AsyncCell when making the change.
- Around line 1046-1048: The constructor currently seeds AsyncCell (constructor)
and AsyncTaskCell with Promise.resolve(null) but types promise Promise<T>, which
hides a real null until first computation; update the types to reflect the
actual runtime by changing the initial state typing to Promise<T|null> (and
update any related JSDoc return types for get() in AsyncCell/AsyncTaskCell from
Promise<T> to Promise<T|null>) or alternatively replace the pre-resolved null
with an unresolving placeholder Promise created inside the AsyncCell
constructors and keep get() as Promise<T>; pick one approach and apply
consistently to the AsyncCell constructor, AsyncTaskCell, and their get() JSDoc
signatures so callers see the accurate contract.

In `@README.md`:
- Around line 45-48: Update the "Requirements" section in the README to reflect
accurate Node.js support: replace "Node.js 14+" with a safer minimum (e.g.,
"Node.js 14.6+" or "Node.js 16+") because the library relies on
WeakRef/FinalizationRegistry; reference the "Requirements" header and the
current "Node.js 14+" text and explicitly mention WeakRef/FinalizationRegistry
compatibility in the note so users know why the minimum was raised.
- Around line 183-189: The listener passed to dashboard.loaded.listen uses await
but isn't declared async; update the callback (the function passed to
loaded.listen) to be async or remove await and chain promises instead —
specifically make the callback async so you can await
dashboard.values.profile.get() and dashboard.values.posts.get() before calling
renderDashboard(profileData, postsData), or alternatively replace awaits with
Promise.all on dashboard.values.profile.get() and dashboard.values.posts.get()
and then call renderDashboard with the resolved values.
🧹 Nitpick comments (1)
library/classes.js (1)

1373-1390: AsyncTaskCell inherits AsyncCell's constructor which immediately calls computedFn() — review the double-invocation path.

super(computedFn) at Line 1387 invokes AsyncCell's constructor, which calls this.computedFn() at Line 1147. At that point hasInput is false, so computedFn returns Promise.resolve(null). Then the full AsyncCell machinery (runId tracking, abort controller, tripwire, .finally() handler) all run for this no-op initial promise.

Immediately after, this.pending.set(false) (Line 1390) overrides the pending = true that AsyncCell just set. This works but is wasteful — it creates and discards an AbortController, allocates a tripwire promise, and triggers the entire .finally() chain for a dummy value. If this is intentional scaffolding, a brief comment would help future maintainers understand why.

Comment on lines +1009 to +1028
/**
* @protected
* Aborts the current computation if one is running.
* @returns {void}
*/
abort() {
this.#controller?.abort();
}

/**
* Gets the AbortSignal for the current computation.
* @protected
* @returns {AbortSignal}
*/
get _signal() {
if (!this.#controller) {
this.#controller = new AbortController();
}
return this.#controller.signal;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

abort() is marked @protected but documented as public in the PR summary.

The PR summary states "AsyncCell exposes a public abort() method for external cancellation," but the JSDoc says @protected. If external cancellation is intended, remove @protected; otherwise, update the PR description.

🤖 Prompt for AI Agents
In `@library/classes.js` around lines 1009 - 1028, The JSDoc for abort()
contradicts the PR summary: if AsyncCell is intended to expose abort() publicly,
remove the `@protected` tag from the abort() JSDoc (and ensure the description
still returns {void}) so the method is documented as public; if abort() should
stay protected, update the PR description to not claim a public abort()
API—refer to the abort() method on AsyncCell when making the change.

Comment on lines 1046 to 1048
constructor(fn) {
/** @type {Promise<T | null>} */
const initialState = Promise.resolve(null);
const initialState = /** @type {Promise<T>} */ (Promise.resolve(null));
super(() => initialState);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Initial state Promise.resolve(null) is type-cast to Promise<T>.

Both AsyncCell (Line 1047) and AsyncTaskCell (Line 1382) use Promise.resolve(null) cast to Promise<T>. Consumers calling get() before the first real computation completes will receive null disguised as T. The return type is declared as Promise<T> (Lines 1206, 1226), not Promise<T | null>.

This is a known trade-off in the JS/JSDoc world, but it contradicts the commit message "Refined AsyncCell types to return Promise<T> (no longer Promise<T | null>)." Consider documenting this pre-resolution null behavior or typing the initial state more honestly as Promise<T | null>.

🤖 Prompt for AI Agents
In `@library/classes.js` around lines 1046 - 1048, The constructor currently seeds
AsyncCell (constructor) and AsyncTaskCell with Promise.resolve(null) but types
promise Promise<T>, which hides a real null until first computation; update the
types to reflect the actual runtime by changing the initial state typing to
Promise<T|null> (and update any related JSDoc return types for get() in
AsyncCell/AsyncTaskCell from Promise<T> to Promise<T|null>) or alternatively
replace the pre-resolved null with an unresolving placeholder Promise created
inside the AsyncCell constructors and keep get() as Promise<T>; pick one
approach and apply consistently to the AsyncCell constructor, AsyncTaskCell, and
their get() JSDoc signatures so callers see the accurate contract.

README.md Outdated
Comment on lines +45 to +48
### Requirements

Source cells are the building blocks of your reactive state. They hold values that can change over time, automatically notifying dependents when updates occur.
- ES2020+ environment (browsers, Node.js 14+, Deno, Bun)
- TypeScript 4.5+ (optional, for type definitions)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Node.js 14 may not fully support ES2020+.

Node.js 14 has partial ES2020 support (e.g., optional chaining landed in 14.0, but WeakRef / FinalizationRegistry arrived in 14.6). Since the library uses WeakRef extensively, consider bumping the stated minimum to Node.js 14.6+ or Node.js 16+ for safety.

🤖 Prompt for AI Agents
In `@README.md` around lines 45 - 48, Update the "Requirements" section in the
README to reflect accurate Node.js support: replace "Node.js 14+" with a safer
minimum (e.g., "Node.js 14.6+" or "Node.js 16+") because the library relies on
WeakRef/FinalizationRegistry; reference the "Requirements" header and the
current "Node.js 14+" text and explicitly mention WeakRef/FinalizationRegistry
compatibility in the note so users know why the minimum was raised.

Add missing async keyword to the dashboard.loaded listener.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@README.md`:
- Around line 133-139: The snippet uses top-level await with user.get(), which
can break in non-ESM contexts; update the example to avoid top-level await by
wrapping the call in an async function or using a Promise callback.
Specifically, change the usage around user.get() (and keep user.pending.listen /
user.error.listen as-is) so the data retrieval is performed inside an async
function or via user.get().then(...) to ensure the example runs in CommonJS and
browser scripts without top-level-await support.

Comment on lines +133 to 139
// Built-in status tracking
user.pending.listen((isLoading) => console.log(isLoading ? 'Loading...' : 'Done'));
user.error.listen((err) => err && console.error(err));

// Access the data
const data = await user.get();
```
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid top‑level await in the generic JS snippet.

Unless you’re explicitly documenting ESM + top‑level await, this will be copied into non‑module contexts and fail. Wrap in an async function or use .then.

Suggested fix
-// Access the data
-const data = await user.get();
+// Access the data
+(async () => {
+  const data = await user.get();
+  // use data
+})();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Built-in status tracking
user.pending.listen((isLoading) => console.log(isLoading ? 'Loading...' : 'Done'));
user.error.listen((err) => err && console.error(err));
// Access the data
const data = await user.get();
```
// Built-in status tracking
user.pending.listen((isLoading) => console.log(isLoading ? 'Loading...' : 'Done'));
user.error.listen((err) => err && console.error(err));
// Access the data
(async () => {
const data = await user.get();
// use data
})();
🤖 Prompt for AI Agents
In `@README.md` around lines 133 - 139, The snippet uses top-level await with
user.get(), which can break in non-ESM contexts; update the example to avoid
top-level await by wrapping the call in an async function or using a Promise
callback. Specifically, change the usage around user.get() (and keep
user.pending.listen / user.error.listen as-is) so the data retrieval is
performed inside an async function or via user.get().then(...) to ensure the
example runs in CommonJS and browser scripts without top-level-await support.

@adebola-io adebola-io merged commit 9fb5142 into main Feb 8, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant