Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

$derived unexpectedly reevaluate #15414

Open
CNSeniorious000 opened this issue Mar 1, 2025 · 8 comments
Open

$derived unexpectedly reevaluate #15414

CNSeniorious000 opened this issue Mar 1, 2025 · 8 comments

Comments

@CNSeniorious000
Copy link

CNSeniorious000 commented Mar 1, 2025

Describe the bug

The reactivity system don't work as expect.

Consider the code below:

  1. cycle is an iterator, calling cycle() will get 1, 2, 1, 1, then 1, 2, 1, 1, etc.
  2. a is a $state, and I invoke an setInterval to () => a = cycle(), so that a will be 1, 2, 1, 1 ... right?
  3. const b = $derived(a)
  4. const c = $derived(b)
  5. an $effect used c. Let's say $effect(() => { const _ = c }

So, the ideal results should be:

  1. when the effect first run, it used c, c used b, b used a, and a is 1;
  2. when a set to 2, b invalidates and set to 2, c invalidates and set to 2;
  3. when a set to 1, b invalidates and set to 1, c invalidates and set to 1;
  4. when a set to 1, b doesn't invalidate, c doesn't invalidate;
  5. when a set to 2, b invalidates and set to 2, c invalidates and set to 2;

...

Right? But now in the #4 step, b doesn't invalidate, but c invalidates.

In the reporduction below, I replaced the cycle with a button to let you control more.

And I log every $derived reruns by using

let b = $derived((console.log("b", { a }), a));

Reproduction

https://svelte.dev/playground/a727bfaac87f41bdbe3d007f9928e5dc?version=5.20.5

Open the console and click the button 3 times.

After you clicked the 4th time, you are on the #4 state described above.

Expected output:

a { value: 1 }
b { a: 1 }
c { b: 1 }
a { value: 2 }
b { a: 2 }
c { b: 2 }
a { value: 1 }
b { a: 1 }
c { b: 1 }
a { value: 1 }

Actual output:

a { value: 1 }
b { a: 1 }
c { b: 1 }
a { value: 2 }
b { a: 2 }
c { b: 2 }
a { value: 1 }
b { a: 1 }
c { b: 1 }
a { value: 1 }
c { b: 1 }     <- this line should never show!!

Logs

System Info

  System:
    OS: Windows 11 10.0.26100
    CPU: (16) x64 AMD Ryzen 7 7840HS w/ Radeon 780M Graphics
    Memory: 3.25 GB / 14.68 GB
  Binaries:
    Node: 22.14.0 - D:\Program Files\nodejs\node.EXE
    npm: 10.9.2 - D:\Program Files\nodejs\npm.CMD
    pnpm: 10.5.2 - ~\.bun\bin\pnpm.EXE
    bun: 1.2.4 - ~\.bun\bin\bun.EXE
  Browsers:
    Edge: Chromium (131.0.2903.86)
    Internet Explorer: 11.0.26100.1882
  npmPackages:
    svelte: ^5.20.5 => 5.20.5

Severity

annoyance

@paoloricciuti
Copy link
Member

Mmm weirdly this doesn't happen if you actually return b for c instead of just logging...I suppose it's something that has to do with undefined (the return value of console.log)

@CNSeniorious000
Copy link
Author

CNSeniorious000 commented Mar 1, 2025

Returning b or anything wraps b like [b] or String(b) also won't cause this.

But other simple values like "", 123 reproduce the same.

BTW, It would be great to have some documentation explaining the internal mechanics of the internal implementation of Svelte's reactivity system. After all, both SolidJS and Vue provide extensive details about their inner workings.


Actually, I stumbled upon this "bug" today while exploring Svelte's $derived. I noticed that it sits somewhere between SolidJS's createMemo (eagerly-compute but lazily-invalidate) and SolidJS Primitives' createLazyMemo (eagerly-invalidate but lazily-recompute). Svelte's $derived seems to be both lazily-invalidate and lazily-recompute. Unfortunately, I couldn't find any documentation that clarifies this, and I’m not ready to dive straight into the source code just yet.

So I’m still really curious about how it manages to balance both behaviors. I’ve noticed that recomputation always propagates from deeper $derived to shallower ones, but if there isn’t an outer $effect to pull the outermost $derived, none of these recomputing happens. This makes me suspect that Svelte’s reactivity system might involve a 2-pass propagation mechanism... If anyone could point me to some source files or provide guidance on where to start looking, I’d be super grateful! (I’m currently working on implementing reactivity and HMR in Python, so I’ve been diving into the implementations of various frontend frameworks lately.)

@paoloricciuti
Copy link
Member

So i was exploring this a bit and there's definitely a bug...the problem is that since c is always returning the same value the write_version of the effect if low since it never reruns. But the read_version of b (which is the dependency of c is high since the first two times actually reruns. So c is considered dirty because b read versions is higher than the effect read version.

I still need to figure out the right fix tho.

@trueadm
Copy link
Contributor

trueadm commented Mar 3, 2025

I tried playing around with the logic in check_dirtiness in runtime.js:

for (i = 0; i < length; i++) {
	dependency = dependencies[i];
	var prev_version = dependency.wv;
	var reaction_wv = reaction.wv;

	if (dependency.wv > reaction_wv) {
		dependency.wv = reaction_wv;
	}

	if (check_dirtiness(/** @type {Derived} */ (dependency))) {
		update_derived(/** @type {Derived} */ (dependency));
	} else {
		dependency.wv = prev_version;
	}

	if (dependency.wv > reaction_wv) {
		return true;
	}
}

This fixes the issue above, but one test now fails derived-write-read-write-read. Gotta run now, but maybe that can help @paoloricciuti with your investigation.

@paoloricciuti
Copy link
Member

@trueadm this doesn't actually fix the issue...weirdly because i tested it before and i remember seeing it working but now i can't seem to fix it anymore.

@trueadm
Copy link
Contributor

trueadm commented Mar 3, 2025

@paoloricciuti Yeah it doesn't for me either now. I'll revisit tomorrow lol.

@paoloricciuti
Copy link
Member

@paoloricciuti Yeah it doesn't for me either now. I'll revisit tomorrow lol.

I think I've also fumbled before: in reality the reaction that has the wrong version is c because a derived version is only increased when the value changes. But then we check if dependency version is greater to determine if it's dirty...this feels wrong to me because if the value of a derived never change even tho it has a dependency on something the fact that dependency has a bigger version is not an indication of the dirtyness.

Still can't wrap my head around how to fix it tho

@trueadm
Copy link
Contributor

trueadm commented Mar 4, 2025

So after digging into this more – this behaviour, whilst not ultimately desirable, is also not a bug. Deriveds today are not guaranteed to never re-run – they can in plenty of situations due to the state of the reactive graph. Given they're pure functions, they should be no issue other than doing extra work in the odd case they do over-fire. Effects should never over-fire because of the state of the reactive graph though – and in this case the effect is working fine.

For us to force that the derived cannot update here means that we lose a very important facet of reactivity – in the case where you want to schedule an effect and then switch the value back to what it was before. This is very common in emulating Svelte 4 reactivity in many cases and thus changing this functionality would break too much of the ecosystem.

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 a pull request may close this issue.

3 participants