-
-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
breaking: use structuredClone
inside $state.snapshot
#12413
Conversation
🦋 Changeset detectedLatest commit: 8ec1c9f The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
packages/svelte/src/compiler/phases/3-transform/server/transform-server.js
Outdated
Show resolved
Hide resolved
The types aren't perfect: it's possible to pass non-cloneable objects to class Foo {
x = 1;
y = $state(2);
}
const snapshot = $state.snapshot(new Foo()); ...the type of {
x: number;
readonly y: number;
} You also don't get a type error if you pass in an uncloneable object. We might just have to live with these limitations. On the positive side, it correctly converts e.g. |
Ugh, the types are okay for the |
Apart from whether or not we need to expose the snapshot type: I think it could work if you do |
Ah, you're totally right. I lean towards not exposing it until a need arises though, especially since it's not 100% correct |
I think we should rename this API to |
Why are we better off with throwing an error? Can't we just leave the state alone? Why would the user of |
If you freeze the proxied object, it won't do anything and can just generally cause strange issues all over. Good read: https://tvcutsem.github.io/frozen-proxies |
There are two possibilities — we freeze the argument, or we don't. If we freeze it, we're freezing the original state which will do strange things. If we don't, then we're literally just returning the argument, which is pointless and indicates that the user messed up somehow. Throwing an error and being clear about expectations circumvents the whole mess |
We're already no longer freezing the object in prod for performance reasons, instead there's just a symbol marker on the object. If we skip freezing in dev as well (which would be good for consistency anyway - people could rely on the frozen behavior and be in for a surprise in prod), then we don't have any of those problems. |
Right but do we add |
Then people will just mutate the object. I don’t see what’s wrong with the error. |
I mean "rely on it" in the sense of having a
What will be weird about it? People use it like an object that is immutable, and possibly in other places it's used differently. Both can be ignorant of the other side. Nothing weird coming out of that from my perspective. I'd be fine with a dev time warning though since it's most likely an unintended mistake - but it doesn't have to be: maybe you can't really control what you get passed since you're getting part of the data from elsewhere. |
weird outcomes scenario 1: we freeze the argument <!-- Thing.svelte -->
<script>
let { object } = $props();
// one frozen object please mr svelte!
let frozen = $state.frozen(object);
</script> <script>
import Thing from './Thing.svelte';
let object = $state({...});
function update() {
object.x += 1; // hey wtf why didn't this work????
}
</script>
<Thing {object} />
<button onclick={update}>update</button> weird outcomes scenario 2: we don't freeze the argument <!-- Thing.svelte -->
<script>
import OtherThing from './OtherThing.svelte';
let { object } = $props();
// sure am glad this object is frozen so that `<OtherThing>` can't muck about with it!
let frozen = $state.frozen(object);
</script>
<OtherThing {frozen} /> <!-- OtherThing.svelte -->
<script>
let { frozen } = $props();
frozen.x += 1; // :trollface:
</script> There's no right answer between these two. We can have extensive documentation detailing all the counterintuitive ways |
Again, I'm saying "stop freezing the object in dev mode because we're already not doing that in prod". That only leaves scenario 2, and mutating something does not hurt at all there. It has no practical outcome on the one freezing the object. I'm not gonna die on this hill but my rule is "avoid throwing errors if there's sensible (fallback) behavior", and in this case that's doable for me - throwing always feels like a cop-out to me. |
In the example above, if <!-- Thing.svelte -->
<script>
import OtherThing from './OtherThing.svelte';
let { object } = $props();
// sure am glad this object is frozen so that `<OtherThing>` can't muck about with it!
let frozen = $state.frozen(object);
+ function update_frozen(value) {
+ frozen = value;
+ }
</script>
<OtherThing {frozen} /> ...in which case mutations inside The entire purpose of let a = $state({...});
let b = $state.frozen(a); ...then we know, without any doubt whatsoever, that they fucked up. There is no "sensible (fallback) behavior". Throwing an error here is our responsibility. |
(The question of whether or not we use |
If I were to use If we stopped freezing and called it As I said I'm not going to die on this hill, so if these arguments don't convince you then go ahead with the error. |
I'd have the same expectations around So I'm definitely open to that. If we wanted to take a leaf out of MobX's book (since their terminology is pretty well established, and well understood by many people) we would call it But in the meantime I'll merge this since it has one approval and you're at peace with it, so that we lock in the |
* move cloning logic into new file, use structuredClone, add tests * changeset * breaking * tweak * use same cloning approach between server and client * get types mostly working * fix type error that popped up * cheeky hack * we no longer need deep_snapshot * shallow copy state when freezing * throw if argument is a state proxy * docs * regenerate
* move cloning logic into new file, use structuredClone, add tests * changeset * breaking * tweak * use same cloning approach between server and client * get types mostly working * fix type error that popped up * cheeky hack * we no longer need deep_snapshot * shallow copy state when freezing * throw if argument is a state proxy * docs * regenerate
fixes #12128 (and possibly others, unsure)
We previously decided that
$state.snapshot
should only de-proxify its argument, and whatever state it contained. In other words in a case like this......the first snapshot would be de-proxified but the second one wouldn't.
This was a bad call. It's wantonly confusing, prone to surprising breakage, and doesn't work for common scenarios like serializing classes with state fields. We also don't clone objects that aren't state proxies, meaning that if you mutate parts of a snapshot it will mutate the underlying object, and vice versa — the opposite of what 'snapshot' implies.
The approach in this PR is a lot more robust. It's basically
structuredClone
with a couple of edits:structuredClone
balks at proxies)toJSON
method, we use itA few consequences of this design:
structuredClone
normally, if you have an object with a getter, the getter is invoked. This is deliberate, because interacting with a getter/setter is likely to change the underlying state, which is undesirable. Functions don't get cloned, and there's no reason accessors should be exempt from that rulesvelte/reactivity
classes are turned into their non-reactive counterparts — e.g. a snapshottedSvelteSet
becomes a normalSet
(not because of any special handling, but just because that's howstructuredClone
works when encountering an object whereinstanceof Set
is true)toJSON
on the class$state.snapshot
isn't something you should be using in a hot code path. This is a case where correctness and predictability are far more important than eking out a few extra microsecondsAt present, the types are wrong. I suspect it'll take some trickery to make them behave correctly. Will take a swing at it but might need to call @dummdidumm for backup...
Before submitting the PR, please make sure you do the following
feat:
,fix:
,chore:
, ordocs:
.Tests and linting
pnpm test
and lint the project withpnpm lint