Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

breaking: usestructuredClone inside$state.snapshot#12413

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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
Rich-Harris merged 14 commits intomainfromstructured-clone-snapshot
Jul 14, 2024

Conversation

Rich-Harris
Copy link
Member

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...

constobj=$state({...});console.log($state.snapshot(obj));console.log($state.snapshot({ obj}));

...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 thataren'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 basicallystructuredClone with a couple of edits:

  • state proxies are de-proxified (sincestructuredClone balks at proxies)
  • if a class has atoJSON method, we use it

A few consequences of this design:

  • we no longer preserve non-enumerable properties, or descriptors more generally — just like withstructuredClone 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 rule
  • Changing parts of the snapshot won't affect the original state, and vice versa. This extends to things like maps and sets
  • Instances ofsvelte/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)
  • Userland classes are turned into POJOs, including classes with state fields. Note that state fields are non-enumerable, so if you have a state field that you want to snapshot, you should implementtoJSON on the class
  • It's slower, and that's okay.$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 microseconds

At 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

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC:https://github.com/sveltejs/rfcs
  • Prefix your PR title withfeat:,fix:,chore:, ordocs:.
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests and linting

  • Run the tests withpnpm test and lint the project withpnpm lint

gterras, kran6a, jamesst20, brunnerh, paolodina, bogacg, and domuk-k reacted with thumbs up emoji
@changeset-botchangeset-bot
Copy link

changeset-botbot commentedJul 11, 2024
edited
Loading

🦋 Changeset detected

Latest commit:8ec1c9f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
sveltePatch

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

@Rich-Harris
Copy link
MemberAuthor

The types aren't perfect: it's possible to pass non-cloneable objects tosnapshot (likeURL), and I can't figure out a way to exclude prototype properties from the type of the clone — i.e. in a case like this...

classFoo{x=1;y=$state(2);}constsnapshot=$state.snapshot(newFoo());

...the type ofsnapshot will be this, even though the object will not have ay property:

{  x:number;  readonlyy: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.SvelteSet toSet.

@Rich-Harris
Copy link
MemberAuthor

Ugh, the types are okay for thesnapshot function itself, but of course they don't magically apply to$state.snapshot, and it's not possible to import theSnapshot type inambient.d.ts...

@dummdidumm
Copy link
Member

Apart from whether or not we need to expose the snapshot type: Ithink it could work if you dofunction snapshot (..): import("svelte").Snapshot

@Rich-Harris
Copy link
MemberAuthor

Ah, you're totally right. I lean towardsnot exposing it until a need arises though, especially since it's not 100% correct

@Rich-HarrisRich-Harris marked this pull request as ready for reviewJuly 11, 2024 21:20
@PuruVJ
Copy link
Collaborator

PuruVJ commentedJul 12, 2024
edited
Loading

import("svelte").Snapshot

I foresee this 👀

CleanShot 2024-07-12 at 11 30 51

PatrickG and baseplate-admin reacted with laugh emoji

@trueadm
Copy link
Contributor

I think we should rename this API to$state.clone now that it's not really making a snapshot anymore. We could keep the existing logic for$state.snapshot and deprecate the API and say it will be removed, to reduce friction maybe.

mbokil and AlbertMarashi reacted with thumbs up emoji

@dummdidumm
Copy link
Member

Why are we better off with throwing an error? Can't we just leave the state alone? Why would the user of$state.frozen care about that? They would use it in accordance to the$state.frozen contract and everything just works.

@trueadm
Copy link
Contributor

trueadm commentedJul 12, 2024
edited
Loading

Why are we better off with throwing an error? Can't we just leave the state alone? Why would the user of$state.frozen care about that? They would use it in accordance to the$state.frozen contract and everything just works.

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

@Rich-Harris
Copy link
MemberAuthor

Can't we just leave the state alone?

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

@dummdidumm
Copy link
Member

dummdidumm commentedJul 12, 2024
edited
Loading

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.

@Rich-Harris
Copy link
MemberAuthor

Right but do we addSTATE_FROZEN_SYMBOL to the argument even if it's already a reactive state proxy? If we do, it will have weird outcomes. If we don't, it will have weird outcomes. The only way to avoid weird outcomes is to disallow it!

@trueadm
Copy link
Contributor

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.

Then people will just mutate the object. I don’t see what’s wrong with the error.

@dummdidumm
Copy link
Member

dummdidumm commentedJul 12, 2024
edited
Loading

Then people will just mutate the object.

I mean "rely on it" in the sense of having aObject.isFrozen check, which istrue in dev andfalse in prod.

If we don't, it will have weird outcomes

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.

@Rich-Harris
Copy link
MemberAuthor

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>importThingfrom'./Thing.svelte';let object=$state({...});functionupdate() {object.x+=1;// hey wtf why didn't this work????  }</script><Thing {object} /><buttononclick={update}>update</button>

weird outcomes scenario 2: we don't freeze the argument

<!-- Thing.svelte --><script>importOtherThingfrom'./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$state.frozen will behave in these situations, or we can just... disallow it

@dummdidumm
Copy link
Member

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.

lin72h reacted with thumbs up emoji

@Rich-Harris
Copy link
MemberAuthor

mutating something does not hurt at all there

In the example above, ifOtherThing.svelte mutatesfrozen it will cause updates to that object in other places, against the wishes ofThing.svelte. Unless of course we reassignfrozen...

<!-- 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 insideOtherThing.sveltewon't cause updates.

The entire purpose of$state.frozen({...}) is to not have properties be reactive. If they'realready reactive, then$state.frozen isn't doing anything except ensuring confusing behaviour if the value is later reassigned. If someone writes this...

leta=$state({...});letb=$state.frozen(a);

...then weknow, without any doubt whatsoever, thatthey fucked up. There is no "sensible (fallback) behavior". Throwing an error here is our responsibility.

@Rich-Harris
Copy link
MemberAuthor

(The question of whether or not we useObject.freeze in dev is separate. I can see arguments for all three options — always freeze, never freeze, or the status quo — but if we decide not to freeze in dev then we should probably call the API something different.)

@dummdidumm
Copy link
Member

dummdidumm commentedJul 14, 2024
edited
Loading

If I were to use$state.frozen it wouldn't be to ensure it is never mutated, it is more about perf and/or knowing it's not proxified by me. If it already was proxified then I possibly can't control that, hence my suggestion to only issue a warning. Furthermore, the "is it not proxified"/"is it frozen" behavior only applies one level deep, so it's not really guaranteed anyway.

If we stopped freezing and called it$state.raw would that change any of your expectations of this to lean more towards my arguments?

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.

@Rich-Harris
Copy link
MemberAuthor

If we stopped freezing and called it$state.raw would that change any of your expectations of this to lean more towards my arguments?

I'd have the same expectations aroundfrozen.x += 1 not causing{frozen.x} to update, and around reassignments tofrozen not behaving differently to the original declaration. But I would no longer expect the object to be frozen withObject.freeze.

So I'm definitely open to that. If we wanted totake a leaf out of MobX's book (since their terminology is pretty well established, and well understood by many people) we would call it$state.ref instead of$state.raw.

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 thesnapshot improvements. We can look into the question of whether$state.frozen should stop freezing in dev and be renamed to something else as a follow-up.

@Rich-HarrisRich-Harris merged commit8d3c026 intomainJul 14, 2024
9 checks passed
@Rich-HarrisRich-Harris deleted the structured-clone-snapshot branchJuly 14, 2024 13:01
@github-actionsgithub-actionsbot mentioned this pull requestJul 14, 2024
trueadm pushed a commit that referenced this pull requestJul 16, 2024
* 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
trueadm pushed a commit that referenced this pull requestJul 16, 2024
* 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
Sign up for freeto join this conversation on GitHub. Already have an account?Sign in to comment
Reviewers

@trueadmtrueadmtrueadm approved these changes

@dummdidummdummdidummAwaiting requested review from dummdidumm

Assignees
No one assigned
Labels
None yet
Projects
None yet
Milestone
No milestone
Development

Successfully merging this pull request may close these issues.

Svelte 5, I'm using$state.snapshot but a nested array is still aProxy
4 participants
@Rich-Harris@dummdidumm@PuruVJ@trueadm

[8]ページ先頭

©2009-2025 Movatter.jp