firsthand

ADR-0028: Async work has an owner

Status: accepted · 2026-09-22 · extends ADR-0008, ADR-0006

Problem

The owner tree already knows how to end things. An effect that runs again calls clearScope first, so everything the previous run made is disposed before the new one starts; a component that goes away takes everything under it with it. That mechanism is what makes “one committed reality” true of synchronous work.

It cannot reach a promise. An await resumes with no ambient owner at all, so after the first suspension the continuation belongs to nothing:

onClick={async () => {
  const saved = await save(draft.value);
  onCleanup(() => ...);   // warns: outside any scope. Never runs.
  useContext(Theme);      // not the component's context
  status.value = saved;   // the component may be long gone
}}

runWithOwner does not fix this, and cannot: it is setOwner plus a finally, which is a synchronous bracket. This is the same limitation Solid documents for its own owner model, and it is ours.

The consequence is a write that lands on nobody. write() does not check DISPOSED, and disposeCell has already unlinked the subscribers, so a stale continuation writing to a dead component’s signal changes a value, propagates to no one, throws nothing and logs nothing. It is the least visible class of bug this framework can produce.

@firsthandjs/data had already run into all of it. useResource aborts on re-run, re-checks entry.disposed after every await, and re-runs when tags arrive late; useAction hand-wrote the same abort-and-guard a second time. Two copies of one rule is the usual sign that a primitive is missing.

Constraints

Decision

A task is the adapter between the owner tree and a promise.

effect(() => {
  task(async ({ signal, resume }) => {
    const user = await resume(fetchUser(id.value, { signal }));
    profile.value = user; // reached only while this is still the current run
  });
});

It holds an AbortController, hangs a scope off the current owner, and asks that owner to abort it on disposal. That is all it is. Supersession is not implemented here at all — it falls out of clearScope, because an effect that runs again disposes the task the previous run started, which aborts it, which makes the old run’s next resume throw.

Three pieces of surface:

The task’s scope ends when the work ends. Anything meant to outlive it belongs to the owner above.

Why resume is visible

An await cannot be intercepted from userland, so the value coming back is the only place that can ask whether the world still wants this work. Writing that place out is a cost, and it buys the property that a reader can see where a run may stop — the same trade snapshot() makes for a read that is taken once on purpose. A compiler pass could rewrite await inside a task body and remove the ceremony; that is a later, optional convenience, and deliberately not what correctness rests on.

Why the scope is detached

clearScope disposes children before it runs its own cleanups. A task scope sitting in the parent’s child list would therefore have its own cleanups run before the cleanup that aborts it, and they would observe an AbortSignal that still read as live — exactly backwards for code whose job is to react to cancellation. createDetachedOwner gives a scope that inherits context and the error-boundary chain from its parent but is not in its child list, which puts the ordering back in task’s hands. task disposes it on both of its paths, so nothing is leaked by the detachment.

Consequences