Skip to content

Conversation

mtsgrd
Copy link

@mtsgrd mtsgrd commented Aug 10, 2025

Problem

Effects created inside $derived statements are not re-executed when the derived is accessed after being disconnected and reconnected to the reactive dependency graph.

Reproduction scenario:

  1. Component A creates a derived with effects: const result = $derived(/* contains $effect */)
  2. Component A unmounts before effects have a chance to execute
  3. Component B tries to access the same derived
  4. The effects are never executed, even though the derived value is computed correctly

This happens because when a derived is disconnected from the graph (no more reactions), its effects are destroyed via destroy_derived_effects(). When the derived is later reconnected, the effects are not recreated since the derived appears "clean" and doesn't need re-execution.

works here: https://svelte.dev/playground/719a2a834fd34d02a204ac4b7c65973b?version=5.35.3
does not work: https://svelte.dev/playground/719a2a834fd34d02a204ac4b7c65973b?version=5.35.4

Solution

Introduce a HAS_EFFECTS flag that tracks when a derived has contained effects, even after they've been destroyed. During reconnection, if a derived has the HAS_EFFECTS flag but no current effects, we force re-execution to recreate them.

Changes:

  1. Add HAS_EFFECTS = 1 << 24 constant
  2. Set the flag in effects.js when effects are added to deriveds
  3. Check for missing effects in runtime.js and trigger re-execution when needed

Implementation

// In effects.js - mark derived as having effects
var derived = /** @type {Derived} */ (active_reaction);
(derived.effects ??= []).push(effect);
derived.f |= HAS_EFFECTS;

// In runtime.js - detect and fix missing effects
if (is_dirty(derived)) {
  update_derived(derived);
} else if ((derived.f & HAS_EFFECTS) !== 0 && derived.effects === null) {
  // Recreate effects they have been destroyed without turning the derived dirty.
  update_derived(derived);
}

Testing

Added a comprehensive test that:

  • Creates a derived with effects in one render context
  • Destroys the context before effects execute (simulating component unmount)
  • Accesses the derived from a new render context
  • Verifies effects execute properly on reconnection

Without fix: 0 effect executions (effects lost forever)
With fix: 1 effect execution (effects recreated on reconnection)

Copy link

changeset-bot bot commented Aug 10, 2025

🦋 Changeset detected

Latest commit: 9b5886d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
svelte Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@mtsgrd mtsgrd marked this pull request as draft August 10, 2025 12:05
@mtsgrd
Copy link
Author

mtsgrd commented Aug 10, 2025

Potential fix for #16594

@mtsgrd mtsgrd marked this pull request as ready for review August 10, 2025 12:54
@mtsgrd mtsgrd force-pushed the missing-effects branch 2 times, most recently from 6d94df6 to e2a1d9f Compare August 10, 2025 13:34
@mtsgrd mtsgrd changed the title fix: fix missing effects if derived added, removed, and re-added as dependency fix: missing effects if derived added, removed, and re-added as dependency Aug 10, 2025
@@ -671,6 +672,10 @@ export function get(signal) {

if (is_dirty(derived)) {
update_derived(derived);
} else if ((derived.f & HAS_EFFECTS) !== 0 && (derived.effects === null || derived.effects.length === 0)) {
Copy link
Author

Choose a reason for hiding this comment

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

The new test can be verified by adding && false to the end of this if statement and running pnpm test.

@mtsgrd mtsgrd changed the title fix: missing effects if derived added, removed, and re-added as dependency fix: recreate effects in derived values after disconnection/reconnection cycle Aug 10, 2025
@mtsgrd mtsgrd changed the title fix: recreate effects in derived values after disconnection/reconnection cycle fix: ensure derived effects reconnect after disconnection Aug 10, 2025
Copy link
Contributor

Playground

pnpm add https://pkg.pr.new/svelte@16595

@mtsgrd
Copy link
Author

mtsgrd commented Aug 10, 2025

@mtsgrd mtsgrd changed the title fix: ensure derived effects reconnect after disconnection fix: ensure derived effects are recreated when needed Aug 11, 2025
@mtsgrd
Copy link
Author

mtsgrd commented Aug 11, 2025

@Rich-Harris is there any chance you could have a look at this? It's a small but important detail in the reactivity system.

@mtsgrd mtsgrd force-pushed the missing-effects branch 2 times, most recently from a2329e0 to 942228e Compare August 11, 2025 20:18
@dummdidumm
Copy link
Member

dummdidumm commented Aug 17, 2025

Solution looks sound from a first look, though can you make it so that the effects property is turned into a special value (the UNITIALIZED symbol for example) and check for that instead of using another flag (so that we have more room for other potential flags in the future)?
Also this needs a test (add one, e.g. your minimum reproduction, to runtime-runes)

@mtsgrd
Copy link
Author

mtsgrd commented Aug 18, 2025

@dummdidumm thanks, I'll have a look at using UNINITIALIZED. Did you want me to add another test, or is the one I added to the end of packages/svelte/tests/signals/test.ts sufficient?

@mtsgrd
Copy link
Author

mtsgrd commented Aug 19, 2025

I'm a bit out of depth here, but based on your suggestion this is the best I could come up with for setting the effects to a special value: gitbutlerapp@75d3381

Having the effects be of type null | effect[] | typeof UNINITIALIZED does not sit too well with me, since it affects places where the value is read. What would you say the path forward is?

Copy link
Member

@dummdidumm dummdidumm left a comment

Choose a reason for hiding this comment

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

Yeah that commit was what I had in mind but after seeing it I agree that a flag is better (we can always go that route if we reach the magic limit of 32 flags and need room). cc @Rich-Harris

Could you add a changeset? Then we're good to go from my side.

@Rich-Harris
Copy link
Member

There's a subtle question around expected behaviour here: is it right that effects are destroyed when nothing is listening to a derived?

Consider this example:

<script>
	class Timer {
		constructor(text) {
			this.elapsed = $state(0);
			this.text = $derived(text + ': ' + this.elapsed);

			$effect(() => {
				const interval = setInterval(() => {
					this.elapsed += 1;
				}, 1000);

				return () => clearInterval(interval);
			});
		}
	}

	let a = $state('hello');
	let b = $derived(new Timer(a));

	let visible = $state(true);
</script>

<input bind:value={a} />
<button onclick={() => visible = !visible}>toggle</button>

{#if visible}
	<p>{b.text}</p>
{/if}

If visible becomes false after a few seconds, the effect is destroyed (this.elapsed stops incrementing) and this.text will be stuck at hello: 3 or whatever when visible becomes true again. That's clearly wrong.

With this PR, the effect is recreated when visible becomes true again. That's good. But it means you recreate the derived (and restart the counter) whenever visible goes from true to false to true even though the value of a hasn't changed. It's like if a tree fell in a forest and no-one was around to hear it, but then someone came to the forest and suddenly the tree was standing again.

To me it feels like the value of b should entirely depend on the value of a, not on a and whether or not something happens to be reading it. Of course if we were serious about that, we would need to execute deriveds when they were first created (which would solve other problems — #16483) but we can get slightly closer with this diff:

diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index 22a1890e0f..363946f292 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -403,6 +403,7 @@ function remove_reaction(signal, dependency) {
        if (
                reactions === null &&
                (dependency.f & DERIVED) !== 0 &&
+               /** @type {Derived} */ (dependency).effects === null &&
                // Destroying a child effect while updating a parent effect can cause a dependency to appear
                // to be unused, when in fact it is used by the currently-updating parent. Checking `new_deps`
                // allows us to skip the expensive work of disconnecting and immediately reconnecting it

A reasonable person could say that as soon as you start using effects in that manner, you're colouring so far outside the lines that you shouldn't have any expectations of the behaviour. I'm not sure. Thoughts?

@mtsgrd
Copy link
Author

mtsgrd commented Aug 28, 2025

A reasonable person could say that as soon as you start using effects in that manner, you're colouring so far outside the lines that you shouldn't have any expectations of the behaviour. I'm not sure. Thoughts?

Consider a slightly more real world example, fetching a resource and then polling it on some interval.

<script>
	let visible = $state(false);
	let counter = 1; // fake response

	function fakeFetch() {
		return counter++;
	}

	function periodicFetch() {
		let data = $state(undefined);

		$effect(() => {
			data = fakeFetch();
			const interval = setInterval(() => {
				data = fakeFetch();
			}, 1000);
			return () => clearInterval(interval);
		});

		return {
			get value() {
				return { data };
			}
		};
	}
	const result = $derived(periodicFetch());
</script>

<div>
	fetch result:
	{#if visible}
		{#if result !== undefined}
			{result.value.data}
		{/if}
	{/if}
</div>
<button onclick={() => (visible = !visible)}>
	{#if visible} stop {:else} start {/if}
</button>

What I intuitively want is:

  • no fetch until I press start
  • no polling unless started
  • stop polling on second click
  • resume polling if restarted

This is actually simplified example of what we're doing at GitButler, and what prompted the investigation.

My own preference would ofc be to merge this change. What do you guys think?

@Rich-Harris
Copy link
Member

That sounds like something that probably ought to be implemented with createSubscriber, irrespective of the outcome of this PR

@mtsgrd
Copy link
Author

mtsgrd commented Aug 28, 2025

TIL.. I think I'll have to stay up late tonight and rewrite a bit of code. 🙏

@Rich-Harris
Copy link
Member

Rich-Harris commented Aug 28, 2025

Here's a createSubscriber implementation. The benefit of this approach is that you can create the Fetcher instance anywhere in your app (for example, in a shared module, so that it can be used by many components) and Svelte won't yell at you for trying to create an $effect outside an effect tree.

Adds HAS_EFFECTS flag to track when deriveds contain side effects and
re-runs computation when effects are missing after reconnection.
@mtsgrd
Copy link
Author

mtsgrd commented Aug 28, 2025

Ok so I'm most likely going to fix the problem on my with createSubscriber, thanks for pointing me in the right direction. I still rebased these changes, squashed the comment from @dummdidumm, and added a changeset. @Rich-Harris feel free to change, merge, or abandon this request?

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.

3 participants