Closed michael closed 5 months ago
This is a tricky one to solve. The reason for this error is because you're assigning to state from outside the constructor (statically that is). When you assign inside the constructor, we can detect it's safe. However it's harder to track these mutations outside and because you're inside a derived
, it means you're possibly mutating something inside a computation which isn't deemed pure.
Whilst we could have some logic that somehow tracked init
to be part of the constructor, it gets complicated for if you called init
in another location. I'm wondering if we should state that the initial values always need to be inside the constructor and can't be broken out into another function so that the compiler can statically figure things out correctly.
I'm still learning about runes but I've been dealing with this issue for a few hours and would like to share what I've learned, because it's a pretty easy solution and it would really help if there was more documentation / discussion about this matter.
ERR_SVELTE_UNSAFE_MUTATION
There's actually a few ways to solve this, but it depends on the exact functionality you're looking for. From what I understand (and my understanding is limited), and as @trueadm mentioned, there is an assignment to a $stateful variable in the init. This confuses the compiler which is blindly going through, seeing a $stateful assignments and thinking they might produce a cascade of side effects, potentially creating a mutation explosion - which of course they might. In your particular case it wouldn't be, but it's hard for the compiler to tell.
The solution is pretty straightforward though. By intelligently using untrack
you can simply have the compiler bypass any kind of reactive assignments or statements that confuse it - which in most cases is actually desirable. Here's a few ways to do it in your code. First you have to think about what exactly you want to have "tracked" by the $derive-ation.
Let's see what you have first:
let entry = $derived(new EntryModel(rawEntry))
which is equivalent to.
let entry = $derived.by(() => new EntryModel(rawEntry))
If you look above, and think of all of the code that's run inside the $derived expression, it's essentially passing the rawEntry, then the entire construction of the class, plus the initialiser. So the compiler is just going through gathering any kind of reactive values it comes across, and checking for assignments to $stateful variables as well. But actually, the only thing you care about is that the construction of the class is reactive to rawEntry. You don't care for it to be reactive to things inside it. It doesn't make sense in this case. So why don't we try...
let entry = $derived.by(() => untrack(() => new EntryModel(rawEntry)) // Doesn't work!
The above doesn't work because we are including everything in untrack
, including rawEntry
- the one thing you want entry
to be reactive to. You can put it into your REPL and see how Load A and Load B buttons don't work, because rawEntry
's signals were "muted" by untrack
you could say. So entry
is essentially a dead signal, keeping only the first value it was assigned. So how do you get the rawEntry
variable to signal entry
for changes, while using untrack
to omit initialiser $stateful stuff? Easy fix.
let entry = $derived.by(() => {
const rawEntryTemp = rawEntry
return untrack(() => new EntryModel(rawEntryTemp)) // untrack conveniently returns whatever the function returns
})
const entry = rawEntry
is not wrapped in untrack
, so the compiler sees it, sees that rawEntry
is a signal, and so marks it as a reactive signal. Now entry
is reactive to rawEntry
. By storing the raw value into a temporary variable, then passing the value into untrack(() => new EntryModel(entry))
, we're essentially using the value given by the signal and passing it into a non-tracked environment. The class is instantiated in that non-tracked environment, so there's no issue there. Everything $state-y in the class initialiser is ignored.
You also don't even have to assign the temporary variable. The fact that rawEntry
is even evaluated outside of untrack
means it will be marked as reactive. For example:
let entry = $derived.by(() => {
rawEntry
return untrack(() => new EntryModel(rawEntry))
}
You can also put untrack
in your init function, and it accomplishes the same thing. There's obviously no point in doing this in addition to the other one. That would be just about as useful as wearing two "raincoats". Just another option.
init(props) {
untrack(() => {
this.name = props.name;
this.description = props.description;
})
}
Just to show you how the compiler doesn't shallowly stay in the $derive function but goes deep, and so you can use untrack
as deep as you like in nested functions or whatnot. Not that you would want to do that most of the time.
Also, I've modified your REPL so that you can see how this works. Also, I added a derived reactive title in addition to your getTitle
function, so you can see how, even when you're not tracking the initialisation of the class, the actual returned value (the EntryModel
instance) has $stateful properties on it and you can use them reactively.
It would be good to have an explanation of the above in the docs!
This is easily solved by wrapping the init
function in an untrack()
, so closing.
Just wanted to say thanks @petermakeswebsites and @trueadm. The solution with wrapping init in untrack works well for me!
Describe the bug
Here's an example code that causes the error.
I don't see the eroror, when the class fields are initialized directly in the constructor instead of
init()
.Reproduction
See this REPL
Logs
No response
System Info
Severity
annoyance