Skip to content

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

@rgon

Description

@rgon

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions