Skip to content

Assignments to $state() break reactivity only out of scope. Breaks returning $state of primitives #13890

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

Closed
rgon opened this issue Oct 24, 2024 · 5 comments

Comments

@rgon
Copy link
Contributor

rgon commented Oct 24, 2024

Describe the bug

For runes to be a competent $store alternative/replacement, we need to be able to create $state inside a method, optionally react to it with any $effect/$derived and then return that proxified $state to a caller.

When creating a $state, then assigning it in a callback (either inside the callback of an $effect, an interval or other method) and returning it from the function, reactivity is lost, so any changes to that $state won't propagate out of scope. However, the $state is still reactive within the scope that it was defined in. REPL - minimal with $effect or REPL - with createInterval

// clock.svelte.js
export function createClock () {
	let clock = $state('')

	$effect(() => {
		clock = new Date().toLocaleString()
	})

       // The state will be set locallly
	$inspect(clock)
	
	// but it won't be returned as such
	return clock
}

This effectively means that we cannot return the $state of a primitive, and the current behavior of $state locally and amongst functions or .svelte.js files seems inconsistent.

However, if we return the $state({}) of an object, we can see that, even though both assignments and mutations trigger reactivity within scope, only mutations get across the function scope. Working Object Mutation REPL

// mutation works
clock.timeString = new Date().toLocaleString()

// assignment only triggers reactivity in the function's scope, not on returned $state
clock = {
	timeString: new Date().toLocaleString()
}

// mutation via Object.assign works again
Object.assign(clock, {
	timeString: new Date().toLocaleString()
})

What's most strange is that assignments to a $state do in fact propagate signals, but they can only be received in the scope the $state was defined in.

Can be worked around by wrapping the return value in an object and ensuring we only assign. Causes confusion when porting from Svelte 4 to 5, since we used to only assign and destructure to trigger reactivity obj = {...obj, value:1 }, and specifically that doesn't work.

Reproduction

Here's a Large REPL table with all 9 combinations:

  • local $state, function-returned $state, function-returned $state from a .svelte.js file
  • return primitive, return object and set inside fn, return Object and mutate inside fn Object.assign
    And buttons to set the state externally, which behave exactly like the closures do.

What's less expected is that if we're assigning locally to that returned $state (columns 1, 2)

  • if the function is inside the same file, assignments won't do anything (row 2)
  • However, if the (exact same) function is imported from a .svelte.js file, the assignment will overwrite the $state, making it diverge from what's inside the function scope. This probably warrants a second issue, once this is fixed.

Please note that wrapping the function return in a $state would be pointless, since in that case, the function's runes/listeners won't receive the event.

let functionState = $state(returnState());

Logs

No response

System Info

svelte.dev playground, `[email protected]`

Severity

blocking an upgrade

@rgon rgon changed the title Assignments to $state() break reactivity if out of scope. Breaks returning $state of primitives Assignments to $state() break reactivity only out of scope. Breaks returning $state of primitives Oct 24, 2024
@trueadm
Copy link
Contributor

trueadm commented Oct 24, 2024

I think you have a misunderstanding of how primitives work in JavaScript, because Svelte adheres to how JS works. In your first example:

export function createClock () {
	let clock = $state('')

	$effect(() => {
		clock = new Date().toLocaleString()
	})

       // The state will be set locallly
	$inspect(clock)
	
	// but it won't be returned as such
	return clock
}

Returning clock will return the value of clock, which will be a number. This won't be reactive as reactively is closed over. To make the reactivity pass the boundary you need to provide a closure to capture the latest value – just how JavaScript works.

export function createClock () {
	let clock = $state('')

	$effect(() => {
		clock = new Date().toLocaleString()
	})

	$inspect(clock)
	
	return () => clock
}

Objects work differently, in that unlike primitives, they're passed by reference in JavaScript. That's why the reactivity remains within them, either via $state proxying the object or via a getter/setter which is essentially a closure like mentioned above.

@adiguba
Copy link
Contributor

adiguba commented Oct 24, 2024

Hello,

You cannot return a state from a function, because this will be replaced by his value at this moment.
You got the solution : return a reactive object containing the value.

Maybe one day we will have an API for this (see this comment : #9237 (comment) )

PS : note that you can use an effect to start/clean the interval

	$effect(() => {
		let interval = window.setInterval(() => {
			clock.timeString = new Date().toLocaleString()
		}, 500);
		return () => clearInterval(interval);
	});

https://svelte.dev/playground/hello-world?version=5.1.0#H4sIAAAAAAAACo1Ty27bMBD8FZY1IAswpPTQiyoLKNJDCxToIceqB4lZxUxoUiDXj0Lgv3ephxUrbtGTwJnd0ews2fFGKnA8-9lx_N0Cz3qAb7iu9uH0uW0TdwSFAasrB7dwYTSCRpLhuRNWtliUukS5b41Fdm_oq4lnjTV7FiXpBRklok9UTg0KkB2lk7UCtmUrhxXCGu0BYirI01la56qqQRW51O0BWXC-LbnYgXipzbnkrJb6MevP8LjtRk1fsIedOc2G8nSQCYrde9lMP_fBTD77TqmiS2VDOE2LcEaeBVt-85fUhDLiZRwueXbX0b0lX-UH5z6z5qAFSqOZsEAh_KifQeB9aGTrmHVTWL3UHFWPl0jBwwNaqZ8yFkUB8_EQ8AqahoTWpLEt2FgehCQZsMdKkdaJojOnxAF-G8Hr8hKHAea_UJOGE_sSLMQJmu9GVGrk1vHQ5Tfs491d2GM4WcCD1Wypa7QzChJlnta0TQWVvViISz41BwOvqcn7xPth4vFOraR2bZh56Xowpi9mep6a_P-teHmHr3d8g_33I-lubNpfHsziykTL_b_p7WNfPJndh-IrKGVYt4zCv8tTYpe3-5f_A6RJ430fBAAA

@trueadm trueadm closed this as not planned Won't fix, can't repro, duplicate, stale Oct 24, 2024
@rgon
Copy link
Contributor Author

rgon commented Oct 24, 2024

@trueadm Thanks for chiming in. I am indeed aware of the differences between primitives and objects, and that primitives can only be assigned, thus my comparison table including both assignments and mutations. I was, however mistaken:

Returning clock will return the value of clock

since I was under the impression that it should return the $state proxy, and not get the value. From switching to the js output tab in the playground:

	return {
		clock: $.get(clock),

Now, how can we access the proxy object? We can definitely read it when calling console.log(), since it complains about it.

To make the reactivity pass the boundary you need to provide a closure to capture the latest value

Your quoted solution doesn't seem to work: REPL

		clock: () => $.get(clock),

Is there a way to get the reactive state proxy, so that it can be used in the parent?

@trueadm
Copy link
Contributor

trueadm commented Oct 24, 2024

It does work, you just didn't call the function. If you have an object proxy, then returning it will always return the reference, as the value is the reference.

@rgon
Copy link
Contributor Author

rgon commented Oct 24, 2024

@trueadm wow sorry, I need to close my laptop and have some sleep, my eyes are no longer working. Thank you for the help!

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

No branches or pull requests

3 participants