Skip to content

fix: use state instead of source in reactive classes #16239

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

Merged
merged 10 commits into from
Jul 9, 2025
12 changes: 6 additions & 6 deletions packages/svelte/src/motion/spring.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { writable } from '../store/shared/index.js';
import { loop } from '../internal/client/loop.js';
import { raf } from '../internal/client/timing.js';
import { is_date } from './utils.js';
import { set, source } from '../internal/client/reactivity/sources.js';
import { set, state } from '../internal/client/reactivity/sources.js';
import { render_effect } from '../internal/client/reactivity/effects.js';
import { tag } from '../internal/client/dev/tracing.js';
import { get } from '../internal/client/runtime.js';
Expand Down Expand Up @@ -170,9 +170,9 @@ export function spring(value, opts = {}) {
* @since 5.8.0
*/
export class Spring {
#stiffness = source(0.15);
#damping = source(0.8);
#precision = source(0.01);
#stiffness = state(0.15);
#damping = state(0.8);
#precision = state(0.01);

#current;
#target;
Expand All @@ -194,8 +194,8 @@ export class Spring {
* @param {SpringOpts} [options]
*/
constructor(value, options = {}) {
this.#current = DEV ? tag(source(value), 'Spring.current') : source(value);
this.#target = DEV ? tag(source(value), 'Spring.target') : source(value);
this.#current = DEV ? tag(state(value), 'Spring.current') : state(value);
this.#target = DEV ? tag(state(value), 'Spring.target') : state(value);

if (typeof options.stiffness === 'number') this.#stiffness.v = clamp(options.stiffness, 0, 1);
if (typeof options.damping === 'number') this.#damping.v = clamp(options.damping, 0, 1);
Expand Down
6 changes: 3 additions & 3 deletions packages/svelte/src/motion/tweened.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { raf } from '../internal/client/timing.js';
import { loop } from '../internal/client/loop.js';
import { linear } from '../easing/index.js';
import { is_date } from './utils.js';
import { set, source } from '../internal/client/reactivity/sources.js';
import { set, state } from '../internal/client/reactivity/sources.js';
import { tag } from '../internal/client/dev/tracing.js';
import { get, render_effect } from 'svelte/internal/client';
import { DEV } from 'esm-env';
Expand Down Expand Up @@ -191,8 +191,8 @@ export class Tween {
* @param {TweenedOptions<T>} options
*/
constructor(value, options = {}) {
this.#current = source(value);
this.#target = source(value);
this.#current = state(value);
this.#target = state(value);
this.#defaults = options;

if (DEV) {
Expand Down
41 changes: 34 additions & 7 deletions packages/svelte/src/reactivity/map.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/** @import { Source } from '#client' */
/** @import { Reaction, Source } from '#client' */
import { DEV } from 'esm-env';
import { set, source, state } from '../internal/client/reactivity/sources.js';
import { label, tag } from '../internal/client/dev/tracing.js';
import { get } from '../internal/client/runtime.js';
import { active_reaction, get } from '../internal/client/runtime.js';
import { increment } from './utils.js';
import { teardown } from '../internal/client/reactivity/effects.js';

/**
* A reactive version of the built-in [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) object.
Expand Down Expand Up @@ -56,13 +57,24 @@ export class SvelteMap extends Map {
#sources = new Map();
#version = state(0);
#size = state(0);
/**@type {Reaction | null} */
#initial_reaction = null;
Copy link
Contributor

Choose a reason for hiding this comment

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

Wondering if there are cases when map still lives, while the reaction could have been GCed and this would prevent it?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah I was wondering about that too...technically it shouldn't be the case because either there's no active reaction (it's outside of a derived/effect) or it's inside it but this means that when the derived/effect is no longer used the function should be GCd and everything inside it too...but I need to do a better check

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe this can become initial_reaction = WeakSet(active_reaction) and we can check with this.initial_reaction.has(active_reaction)?

Copy link
Contributor

Choose a reason for hiding this comment

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

Just for the sake of history, I've also discovered there is a WeakRef struct that could be alternative here. In case of any future issues might be a possible alternative implementation


/**
* @param {Iterable<readonly [K, V]> | null | undefined} [value]
*/
constructor(value) {
super();

if (active_reaction !== null) {
this.#initial_reaction = active_reaction;
// since we only need `initial_reaction` as long as we are in a derived/effect we can
// safely create a teardown function that will reset it to null and allow for GC
teardown(() => {
this.#initial_reaction = null;
});
Copy link
Member

Choose a reason for hiding this comment

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

presumably if there's a situation that would create a memory leak with this (which I guess means smuggling a reference to the map to something outside the current reaction?), teardown only mitigates it, since the reaction in question might never be torn down, if it's an unowned derived.

do we therefore need a way to run code as soon as the current reaction has finished updating? #16316

Copy link
Member

Choose a reason for hiding this comment

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

The alternative is to use a weak ref to reference the reaction. Perf impact should be close to zero since it only applies once for new sources

Copy link
Member Author

Choose a reason for hiding this comment

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

We could also use WeakRef, we were discussing with Simon this morning that this seems the perfect use case for it

Copy link
Member Author

Choose a reason for hiding this comment

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

I've pushed a commit to use WeakRef since it's way easier than handling #16316 for this edge case and this is kinda the perfect use case for it. We can always revert if we don't feel like going for it.

Copy link
Member

Choose a reason for hiding this comment

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

Not sure I love the WeakRef solution tbh — it increases overall memory usage (since every map/set now has to have a WeakRef instance) and slows down every #source call, since WeakRef is known to impact performance

Copy link
Contributor

@gyzerok gyzerok Jul 8, 2025

Choose a reason for hiding this comment

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

Consider it to be the same as previous values in teardown. It's not about if it's right or wrong, it's about the expectations and not making it confusing on the DX side of things

Copy link
Member Author

Choose a reason for hiding this comment

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

That will not introduce a leak...to introduce a leak you would do some weird as Simon pointed out

It can only leak if you create the map inside effect context A and then pass it outside to another context, and context A is destroyed but not the context it's passed to

Copy link
Contributor

Choose a reason for hiding this comment

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

Hm, not sure what I am missing here. Why it won't introduce a leak? As long as I hold to the map somewhere I hold to the $effect scope, no?

Copy link
Member Author

Choose a reason for hiding this comment

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

Duh I misread your code...yeah that will "leak" although only until foo is not garbage collected.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, so my point is that if I write to foo.bar I do understand that the map is held until foo.bar is garbage collected, but I definitely don't expect $effect and everything related to it (dependencies, nodes and everything related to components) to leak as well.

}

if (DEV) {
// If the value is invalid then the native exception will fire here
value = new Map(value);
Expand All @@ -79,6 +91,22 @@ export class SvelteMap extends Map {
}
}

/**
* If the active_reaction is the same as the reaction that created this SvelteMap,
* we use state so that it will not be a dependency of the reaction. Otherwise we
* use source so it will be.
*
* @template T
* @param {T} value
* @returns {Source<T>}
*/
#source(value) {
if (this.#initial_reaction === active_reaction) {
return state(value);
}
return source(value);
}

/** @param {K} key */
has(key) {
var sources = this.#sources;
Expand All @@ -87,7 +115,7 @@ export class SvelteMap extends Map {
if (s === undefined) {
var ret = super.get(key);
if (ret !== undefined) {
s = source(0);
s = this.#source(0);

if (DEV) {
tag(s, `SvelteMap get(${label(key)})`);
Expand Down Expand Up @@ -123,7 +151,7 @@ export class SvelteMap extends Map {
if (s === undefined) {
var ret = super.get(key);
if (ret !== undefined) {
s = source(0);
s = this.#source(0);

if (DEV) {
tag(s, `SvelteMap get(${label(key)})`);
Expand Down Expand Up @@ -154,7 +182,7 @@ export class SvelteMap extends Map {
var version = this.#version;

if (s === undefined) {
s = source(0);
s = this.#source(0);

if (DEV) {
tag(s, `SvelteMap get(${label(key)})`);
Expand Down Expand Up @@ -219,8 +247,7 @@ export class SvelteMap extends Map {
if (this.#size.v !== sources.size) {
for (var key of super.keys()) {
if (!sources.has(key)) {
var s = source(0);

var s = this.#source(0);
if (DEV) {
tag(s, `SvelteMap get(${label(key)})`);
}
Expand Down
35 changes: 32 additions & 3 deletions packages/svelte/src/reactivity/set.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/** @import { Source } from '#client' */
/** @import { Reaction, Source } from '#client' */
import { DEV } from 'esm-env';
import { source, set, state } from '../internal/client/reactivity/sources.js';
import { label, tag } from '../internal/client/dev/tracing.js';
import { get } from '../internal/client/runtime.js';
import { active_reaction, get } from '../internal/client/runtime.js';
import { increment } from './utils.js';
import { teardown } from '../internal/client/reactivity/effects.js';

var read_methods = ['forEach', 'isDisjointFrom', 'isSubsetOf', 'isSupersetOf'];
var set_like_methods = ['difference', 'intersection', 'symmetricDifference', 'union'];
Expand Down Expand Up @@ -51,12 +52,24 @@ export class SvelteSet extends Set {
#version = state(0);
#size = state(0);

/**@type {Reaction | null}*/
#initial_reaction = null;

/**
* @param {Iterable<T> | null | undefined} [value]
*/
constructor(value) {
super();

if (active_reaction !== null) {
this.#initial_reaction = active_reaction;
// since we only need `initial_reaction` as long as we are in a derived/effect we can
// safely create a teardown function that will reset it to null and allow for GC
teardown(() => {
this.#initial_reaction = null;
});
}

if (DEV) {
// If the value is invalid then the native exception will fire here
value = new Set(value);
Expand All @@ -75,6 +88,22 @@ export class SvelteSet extends Set {
if (!inited) this.#init();
}

/**
* If the active_reaction is the same as the reaction that created this SvelteMap,
* we use state so that it will not be a dependency of the reaction. Otherwise we
* use source so it will be.
*
* @template T
* @param {T} value
* @returns {Source<T>}
*/
#source(value) {
if (this.#initial_reaction === active_reaction) {
return state(value);
}
return source(value);
}

// We init as part of the first instance so that we can treeshake this class
#init() {
inited = true;
Expand Down Expand Up @@ -116,7 +145,7 @@ export class SvelteSet extends Set {
return false;
}

s = source(true);
s = this.#source(true);

if (DEV) {
tag(s, `SvelteSet has(${label(value)})`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export default test({
},

test({ assert, target }) {
const [button1, button2] = target.querySelectorAll('button');
const [button1, button2, button3, button4, button5, button6, button7, button8] =
target.querySelectorAll('button');

assert.throws(() => {
button1?.click();
Expand All @@ -19,5 +20,35 @@ export default test({
button2?.click();
flushSync();
});

assert.throws(() => {
button3?.click();
flushSync();
}, /state_unsafe_mutation/);

assert.doesNotThrow(() => {
button4?.click();
flushSync();
});

assert.throws(() => {
button5?.click();
flushSync();
}, /state_unsafe_mutation/);

assert.doesNotThrow(() => {
button6?.click();
flushSync();
});

assert.throws(() => {
button7?.click();
flushSync();
}, /state_unsafe_mutation/);

assert.doesNotThrow(() => {
button8?.click();
flushSync();
});
}
});
Original file line number Diff line number Diff line change
@@ -1,27 +1,101 @@
<script>
import { SvelteMap } from 'svelte/reactivity';

let visibleExternal = $state(false);
let external = new SvelteMap();
const throws = $derived.by(() => {
external.set(1, 1);
return external;
let outside_basic = $state(false);
let outside_basic_map = new SvelteMap();
const throw_basic = $derived.by(() => {
outside_basic_map.set(1, 1);
return outside_basic_map;
});

let visibleInternal = $state(false);
const works = $derived.by(() => {
let internal = new SvelteMap();
internal.set(1, 1);
return internal;
let inside_basic = $state(false);
const works_basic = $derived.by(() => {
let inside = new SvelteMap();
inside.set(1, 1);
return inside;
});

let outside_has = $state(false);
let outside_has_map = new SvelteMap([[1, 1]]);
const throw_has = $derived.by(() => {
outside_has_map.has(1);
outside_has_map.set(1, 2);
return outside_has_map;
});

let inside_has = $state(false);
const works_has = $derived.by(() => {
let inside = new SvelteMap([[1, 1]]);
inside.has(1);
inside.set(1, 1);
return inside;
});

let outside_get = $state(false);
let outside_get_map = new SvelteMap([[1, 1]]);
const throw_get = $derived.by(() => {
outside_get_map.get(1);
outside_get_map.set(1, 2);
return outside_get_map;
});

let inside_get = $state(false);
const works_get = $derived.by(() => {
let inside = new SvelteMap([[1, 1]]);
inside.get(1);
inside.set(1, 1);
return inside;
});

let outside_values = $state(false);
let outside_values_map = new SvelteMap([[1, 1]]);
const throw_values = $derived.by(() => {
outside_values_map.values(1);
outside_values_map.set(1, 2);
return outside_values_map;
});

let inside_values = $state(false);
const works_values = $derived.by(() => {
let inside = new SvelteMap([[1, 1]]);
inside.values();
inside.set(1, 1);
return inside;
});
</script>

<button onclick={() => (visibleExternal = true)}>external</button>
{#if visibleExternal}
{throws}
<button onclick={() => (outside_basic = true)}>external</button>
{#if outside_basic}
{throw_basic}
{/if}
<button onclick={() => (inside_basic = true)}>internal</button>
{#if inside_basic}
{works_basic}
{/if}

<button onclick={() => (outside_has = true)}>external</button>
{#if outside_has}
{throw_has}
{/if}
<button onclick={() => (visibleInternal = true)}>internal</button>
{#if visibleInternal}
{works}
<button onclick={() => (inside_has = true)}>internal</button>
{#if inside_has}
{works_has}
{/if}

<button onclick={() => (outside_get = true)}>external</button>
{#if outside_get}
{throw_get}
{/if}
<button onclick={() => (inside_get = true)}>internal</button>
{#if inside_get}
{works_get}
{/if}

<button onclick={() => (outside_values = true)}>external</button>
{#if outside_values}
{throw_values}
{/if}
<button onclick={() => (inside_values = true)}>internal</button>
{#if inside_values}
{works_values}
{/if}
Loading