DEV Community

flo-dmtx
flo-dmtx

Posted on

`resource({ load: 'whenTracked' })`: what should wake a lazy resource?

A few days ago, I proposed resource({ lazy: true }) for Angular: resources load eagerly by design, and I wanted one that waits until something actually looks at it. This is the follow-up, because the design changed: for the better, and for reasons worth writing down. The whole thing turns on a question that looks trivial and is not: a lazy resource waits. Waits for what, exactly?

Hypothesis 1: any read wakes it

That was v1's answer. Read value(), status(), hasValue(), from anywhere, and the loader fires. I picked that rule for two reasons, and they are solid. No template guard can deadlock, because @if (r.hasValue()) is itself a read. And composition works for free: resourceFromSnapshots, the composition API Angular documents, reads input.snapshot inside a linkedSignal computation. Since that read is a read like any other, wrapping a lazy resource just works.

Then a contributor, wartab, brought the best kind of pushback to the issue thread: a production implementation. Their team runs a userland lazy resource where plain reads do not trigger anything: only long-term interest counts, meaning a template or an effect; untracked never wakes anything; imperative code gets an explicit loadValue() that returns a promise. It works, in production, today.

So: two opposite rules, each backed by something real. Mine by composition, theirs by usage. When two contradictory answers both hold up under pressure, the question is usually cut along the wrong axis. Finding the right axis took two more iterations.

Hypothesis 2: give every intent its own method

The disagreement seemed to be about intent. Some reads mean "I need this data, go get it". Other reads just want to look at the current state without causing anything. v1 collapsed both into one rule; wartab's model collapsed them into another. So the next hypothesis: stop collapsing. If reads carry different intents, give each intent its own entry point.

That design looks like this: peek() reads the state and triggers nothing. toPromise() (wartab's loadValue(), which I adopted under a new name) asks for the value imperatively and waits for it. An option picks the trigger strategy, including one where the resource goes back to sleep when nobody watches it. Every intent gets a door. On paper, nothing is missing.

Building it is what broke it. The sleep-capable strategy kept producing states I could not defend. Wake the resource with a single read from an event handler: nobody is listening, so "the last watcher leaves" can never happen, so it never goes back to sleep. Awake forever, for a caller that got undefined back and will never hear anything more. toPromise() on that same strategy was almost comical: the promise resolves, and the value it just delivered is immediately thrown away, because nobody is still listening. And peek() needed guard after guard to avoid leaking internal states. Every method was an answer to a question that the model itself kept generating. When every edge case needs its own branch, the state model is wrong.

The Rx detour

The tension (what should wake a lazy resource) had been there from the very start. What I was missing was a way to state it in terms that already have answers. So I translated the design into RxJS, a model with a decade of production wear on exactly these questions.

The mapping is short. A resource is a hot observable: it runs whether or not anyone is watching. What my lazy option was trying to revive is the cold observable subscribed by the async pipe, the exact pattern from the first article: nothing runs until the template subscribes, and the subscription is what starts the work. Complete the equivalence with shareReplay (many readers, one execution, the latest value replayed to late arrivals) and it holds: a lazy resource is a cold, shared observable, and listening to it is subscribing to it.

Stated in those terms, two things became visible that I had not managed to see before.

First, refCount. A shared observable can reset when its subscriber count drops back to zero. Modern Rx spells it share({resetOnRefCountZero: true}). That is the moment wartab's model clicked into place for me. The feature their implementation was actually built around is the resource can return to idle, and that is refCount behavior, a named, well-understood thing. loadValue() was never the need; it is the consequence of an implementation choice. Once I could attach "going back to idle" to use cases I understood, the conversation stopped being about which reads should trigger and started being about lifecycle.

Second, subscriptions have callbacks, and a promise is not a stream. Waking a lazy resource is subscribing to it. A read inside a reactive context can genuinely subscribe: it leaves a listener behind, someone the load will actually serve. A read outside a reactive context cannot: it gets a value once, and there is no channel for anything later. Waking the resource for it serves nobody: the caller got undefined and will never be notified. That one distinction resolves the original tension: reads that can subscribe wake the resource; reads that cannot, photograph it. It also kills toPromise() on principle: wanting exactly one value out of a stream is the wrong primitive. The rare genuine need already composes from existing bridges: firstValueFrom(toObservable(...)).

Why would a resource go back to idle?

One thing left to formalize: what is returning-to-idle for? To make a new request? Not quite: reload() already does that. To free the value when nothing is using it? Closer. Combine the two: drop the value when nobody needs it anymore, and fetch fresh data when someone comes back. That is the use case: an automatic refresh on return, with no stale data kept alive in between. Think of a details panel: close it, its data is disposed; reopen it a minute later, it loads current data, not last minute's.

What about refreshing while the resource is being watched? That is reload(), and the split holds for a precise reason: there is exactly one situation where no code has a handle to call reload(): the moment the last watcher leaves. Every other refresh scenario has an event or a code path attached to it, some place where calling reload() is natural. So the last-watcher transition is the single point where the resource must act on its own; everywhere else stays explicit.

From there, the model stops needing methods and collapses into two cases with clean semantics:

resource({ load: 'whenTracked', params, loader });   // first listener starts the load; value kept afterwards
resource({ load: 'whileTracked', params, loader });  // lives exactly while listened to
Enter fullscreen mode Exit fullscreen mode

One invariant behind both: no load ever runs while nothing tracks the resource. when is a trigger, while is a lifetime, and the pair carries the whole contract.

What fell out

The API ended smaller than where v1 started. peek() is gone: a read outside a reactive context already is one. toPromise() is gone too, and it is the telling one: the method both designs kept while disagreeing on everything else. With the right trigger found, it had nothing left to do.

The disagreement dissolved the same way. Liveness propagates transitively through computeds, so listening to a wrapper wakes the source through every layer. wartab's rule and my composition constraint were never in conflict. They were sitting on different axes.

The price in core is small. Two optional hooks on the reactive graph, 22 lines, fired on the live-consumer transitions the graph already maintains (they happen to be the watched / unwatched callbacks of the TC39 Signals proposal). About 190 lines in resource.ts. The public API diff is exactly one option. The lazy behaviors are a 43-spec contract run against both strategies.

Where this goes

The issue is open, an Angular team member left it open for community discussion. To support this feature or take part in the discussion, head to the issue. The playground runs the actual branch diff if you want to watch whileTracked cancel a request in the network log, and the gist is the copy-paste version for today.

Thanks to wartab for the pushback and the discussion.

Top comments (0)