#How does effect flip computed signals from lazy to eager?

57 messages ยท Page 1 of 1 (latest)

brazen escarp
#

So I'm trying to get a bit of a deeper understanding of signals more generally after having used them for a while.
I stumbled over this change of how computed signals get evaluated when they're included in effects which intuitively makes sense but I'm wondering how, on a high-level description, that is implemented.

So say I have a component like this:

export class AppComponent implements OnInit {
    x = signal(1);
    y = computed(() => {
        console.log("Fired Computed", this.x())
        return 2*this.x();
    })

    ngOnInit(): void {
        interval(1000).subscribe(() => {
            console.log("Fired Setting x to ", this.x()+1)
            this.x.set(this.x() + 1)
        })
    }

This will never evaluate y() since its never used anywhere, so for y the computed evaluation is lazy.

But if you now add a constructor with an effect to that component

    constructor() {
        effect(() => {
            console.log("Fired Effect")
            console.log("Fired: ", this.y());
        })
    }

What I observe now is this:

Fired Setting x to  2
app.component.ts:19 Fired Computed 2 
app.component.ts:28 Fired Effect // This here gets logged *before the effect itself ever reads computed*
app.component.ts:29 Fired:  4
app.component.ts:35 Fired Setting x to  3
app.component.ts:19 Fired Computed 3
app.component.ts:28 Fired Effect
app.component.ts:29 Fired:  6

So now computed eagerly gets evaluated before it even gets accessed in the effect.

Conceptually, how is that set up?
I'm already aware there's a global effect registry in Angular which can mark effects as dirty and eventually a tick runs regularly that executes all dirty effects.
But how do you slot into that to change the behavior of all signals inside it?

#

How does effect flip computed signals from lazy to eager?

bold scaffold
#

Your logs are missing the start, where the effect fires for x == 1 and y == 2.
So what happens:

  1. "Fired Effect"
  2. Effect reads y(), this computes it ("Fired computed 1") Angular now knows that the effect reads y and that y reads x
  3. "Fired: 2"
  4. You set x to 2 ("Fired Setting x to 2") <= The logs you posted start here
  5. Angular knows that y reads x and recomputes y, because x changed
  6. Angular knows that your effect reads y and therefor calls your effect, because y changed.
brazen escarp
#

Ah, fair, the full logs from there are:

app.component.ts:28 Fired Effect
app.component.ts:19 Fired Computed 1
app.component.ts:29 Fired:  2
app.component.ts:35 Fired Setting x to  2
app.component.ts:19 Fired Computed 2
app.component.ts:28 Fired Effect
app.component.ts:29 Fired:  4
app.component.ts:35 Fired Setting x to  3
app.component.ts:19 Fired Computed 3
app.component.ts:28 Fired Effect
app.component.ts:29 Fired:  6
#

So is it setup that a computed can either evaluate lazy or eager and if you get registered as having been called in an effect, you get marked as "evaluate eager" until you're no longer found to be associated with an effect?

bold scaffold
#

It's a chain of dependencies, basically, which gets inverted.
Effect => (depends on) y => (depends on) x

Now the order gets inverted:

x => (notifies on change) y => (notifies on change) effect

In the second graph y is a computed and it won't bother computing its value if it has nothing to notify

#

Now if y has nothing to notify, then it won't ever read x (because its compute-function is never called).
This now means the y => (depends on) x part is never set up in that case so x => (notifies on change) y also won't happen. But that's fine, because if y is not needed by anyone, nobody needs to notify y that it needs to recompute its value

#

That way the whole tree becomes "as lazy as possible", where only the computeds that are actually needed are computed and only the notifications that are actually needed to update the ui/run effects /etc are happening

brazen escarp
#

Ohhh so if I say write an effect ala

effect(() => {
  if(firstSignal()) secondSignal()
)}

and firstSignal flips between true/false every 5 seconds while secondSignal changes its value every second, then there will be moments where secondSignal will have value changes that will not result in the effect getting executed?

bold scaffold
#

Absolutely!

#

But the same applies for effects

brazen escarp
#

Are there other entities than effect that cause the setup of an inverse-dependency-graph like this?

I assume not even reading from a signal in a template would do this as I'd assume that still acts lazily like reading from a signal in a method

bold scaffold
#

This "inverse dependency graph" is the core of the whole signal machinery.
effects and computeds use it and it is also used to track which signals your component templates read and those signals then trigger change detection. Think of it like your template running inside an implicit effect, which will trigger re-rendering for any signals you read.

brazen escarp
#

So are signal-invocations in like methods etc. the only things that don't set up inverse dependency graphs (since they're one-offs) ?

bold scaffold
#

It's not about the location in the code, it is about the time.
In the signal implementation this is called the "reactive context", which is the thing that tracks which signals you have read. Every time you call a signal (i.e. access its value) it calls producerAccessed, which basically means "someone cares about this signal". producerAccessed looks at the current reactive context (i.e. "are we currenty in an effect or in a computed") and if so, records that that context cares about the signal being read.
If there is no reactive context (i.e. this is not during an effect or a the function of a computed or a template rendering) then nothing happens and you just get the current value.

#

And effect and computed set the reactive context before calling your function (the function you pass to effect() / computed()) and reset it after

#

It doesn't matter if the call to the signal happens 20 method calls deep, what matters is that it happend during execution of your effect/computed function

brazen escarp
#

Excuse me while I try to turn that into a mental model, might take a bit to also keep the implications in mind etc.

#

Okay, so effect/computed set up reactive contexts, through which they track their signal dependencies at runtime.

Via tracking in reactive contexts they set up a DAG with nodes, where a node is a signal (normal signal or computed doesn't matter I assume) or an effect (template-rendering being an effect) and by virtue of being what they are, effects can only be leaf-nodes on that graph.

If a signal gets updated, then that update also contains a check through all the DAGs that the signal is involved in.
If in any of the DAGs there are paths that run from it to an effect-node, then all signals along that path get eagerly executed with the effect node as the final one with basically the most up-to-date values available.

bold scaffold
#

template rendering is not actually an effect, this works differently as far as I know (this is now getting into territory I really do not know :D). That was just a mental model for how to think about it.

What you described is correct, yes.

#

Just this is a bit misleading I think:

If a signal gets updated, then that update also contains a check through all the DAGs that the signal is involved in.
There is no "check through" here. The signal node just has an array of "consumers" (other nodes that depend on it). Those consumers will be notified on change.

#

Under the hood there is indeed a graph with different types of nodes. Computed are a type of node, effects are a type of node, template rendering is also a type of node.

#

And the template rendering node tells the scheduler that a signal changed. The scheduler is different based on zoneless or zoned, but ultimately causes a change detection run

brazen escarp
# bold scaffold Just this is a bit misleading I think: > If a signal gets updated, then that upd...

But how does that work with eager updates?
Does executing in an effect explicitly register i.e. a computed signal with its base signal as consumer in order to eagerly get notified of updates?

Because I would currently assume a computed signal is not in the consumer array of its dependencies by default, assuming it is only used i.e. in a method that gets called onclick and therefore lazily evaluated.

bold scaffold
#

In your example from the start of this thread before the effect executes there are zero dependency edges between the three nodes.
Then the effect runs and calls y(). This immediately sets up the dependency edge effect => (depends on) y.
Now y needs to return its value and it doesn't have it yet. So it calls the computation-function. The computation function calls x(). This sets up the dependency edge y => (depends on) x.
Now after this you have effect => (depends on) y => (depends on) x.

brazen escarp
#

Check, if you did that as part of a methodCall for an onClick for example, then it would just be a lazy one-off evaluation and since no reactive context is present, no edges are set up etc.

bold scaffold
#

Exactly.

#

I should also say that there is an additional lazyness built into the system. When x notifies y, y is not immediately recomputed. Instead it only marks itself as "dirty" and notifies its consumers (the effect in this case). Only once the value is then accessed again (in this case because the effect calls it) is the new value computed.

#

At least that is what I think is happening from looking at the code. I didn't write this ๐Ÿ˜„

brazen escarp
#

One second, even if the computed signal gets marked as dirty, it must still recompute before the effect reads the signal otherwise the log outcomes from my initial example would be different.

bold scaffold
#

Hm, you're right. I might be missing something in the code then

brazen escarp
#

Because there the computed gets executed (after initial registration) every time before the effect code gets touched

#

Head still mildly spinning from piecing this all together into a mental model ๐Ÿ˜„

#

Actually given that I'm not sure computed actually has a reactive context

#

Because computed itself doesn't really need to track what its dependencies are - it itself never triggers an eager evaluation and is lazy by default, template and effect do that

bold scaffold
#

It absolutely does and it does need to track its dependencies. Otherwise how could it ever know that its dependencies get dirty, i.e. it needs to recompute?

brazen escarp
#

I'll stick with the onclick example:
The invocation in the onClick-method would trigger the recompute.
Memoization could handle skipping unnecessary recomputes.

But is that maybe tied to being marked as dirty? So a computedSignal that is not in an effect or template still does s DAG setup solely to mark it as dirty (not for actually triggering the recomputation) and then the logic when invoking the computed signal then is return signal.dirty ? signal.recompute() : signal.priorValue ?

bold scaffold
#

The DAG is only for "marking dirty" in all the cases ๐Ÿ™‚ That's its whole job.

#

Memoization could handle skipping unnecessary recomputes.
How would you memoize without knowing that your dependencies are dirty?

#

Like, keep in mind if you want to implement computed you cannot change what the function you're given does. You can just call it. You can't change how any signals it reads behave.
So your only way to make it only recompute on changes is to record dependencies and get notified when they change.

brazen escarp
#

I think for me it's already a big revelation that "updating a signal" is a 2 step process:

  1. Mark the thing as dirty (done by DAG during setting the value of a signal)
  2. Trigger the recomputation (done by CD which retriggers computation on all the dirty things... I think)
#

The second bit I haven wrapped my head around but I think I need to let the rest of the setup settle a bit before I can iterate on top of it to understand your point

bold scaffold
#

2 is not because of CD. 2 happens when someone accesses that (now dirty) signal. That can be because of CD, but it could also be an effect that runs.

brazen escarp
#

Wait if that's the case

#

Then the entire mark as dirty mechanism is just a very complicated way of automatic cache invalidation

bold scaffold
#

I'm not sure I follow you there

brazen escarp
#

If you do not mark as dirty, you do not recompute, you receive the same value every time ... wait that description makes only sense for computed, nevermind

tall sedge
#

TLDR, any questions still standing ? ๐Ÿ˜„

brazen escarp
#

Likely yes but I need some time to digest all of this

soft lake
#

@brazen escarp oooh, this is a super good question. You're absolutely right that the computed signal executes before the effect runs.

Consider this chain:

const counter = signal(2);
const isEven = computed(() => counter() % 2 === 0);
effect(() => console.log(isEven());

The effect runs, reading isEven which reads counter and establishing that dependency chain.

Next if you do:

counter.set(4);

This causes the isEven computed & the logging effect to both be marked "potentially dirty". We actually don't know whether the effect needs to rerun at this point, because until we evaluate isEven we don't know whether it actually changed (which of course, it wouldn't since 4 is also even.

What happens is that the effect gets scheduled, and the first thing it does is go through its dependencies and poll them to determine if they've actually changed since the effect last ran. This recomputes isEven and determines that no, it didn't actually change values, so the main effect body is never executed.

If you then do:

counter.set(5);

The same sequence of dirty marking and scheduling happens, except this time when the effect polls its dependences, which recomputes isEven, it notices that now yes, the dependency has actually changed. Therefore the effect body runs, which reads the (now cached) value of isEven.

That operation in the effect flow is here: the consumerPollProducersForChange call is what reads dependencies to check if they've actually changed or not since the last run.

Note that computeds do the same thing - if you have computed B that depends on computed A, computed B will poll computed A to check if it's changed before recomputing itself.

Hopefully this answers your question!

brazen escarp
# soft lake <@180601887916163073> oooh, this is a super good question. You're absolutely rig...

So essentially executing an effect has 3 phases happening across 2 points in time:

  1. when setting a signal: check all your consumers and mark them all as dirty, which recursively also causes their consumers to get marked as dirty until you have reached all your leaf nodes among which is the effect we're paying attention to. This causes all effects to get put on the microtask queue for later execution

  2. later when executing the effect:
    2.1) polling phase: re-execute all your dirty signal dependencies (if any are computed) and check if their values changed. If no, stop executing because nothing changed. If yes, proceed
    2.2) execution phase: set up a reactive context to capture the signal dependencies to track the next time and execute the callback. Since I executed all the computed signals in the polling phase their result is already memorized and thus no double work is ever done.

So basically the computed is, by itself, always lazy.
I just read from it before effect execution in the polling phase which is why it appears eager

#

And what I kinda glossed over is when you add/remove consumers but I assume a consumer is removed as a dependency when you mark it as dirty in phase 1, and you add consumers after executing the effect in phase 2.2 and having tracked all the signal dependencies and who depends on whom

soft lake
#

Mostly yes ๐Ÿ™‚

This causes all effects to get put on the microtask queue for later execution
Effects no longer use a microtask queue

I assume a consumer is removed as a dependency when you mark it as dirty in phase 1,
Nope, only when the effect function re-runs are the dependencies reconciled (if you read a different set of signals than the previous execution).

brazen escarp
#

I at least assume that there's some kind breakup of the execution flow between "set new value on signal" and "run effect", based on the statement that the effect "gets scheduled" (which is the kind of word-choice I usually use when talking about async and the async-dispatcher in lower level programming languages)

soft lake
#

Exactly