sveltejs / svelte

Cybernetically enhanced web apps
https://svelte.dev
MIT License
78.55k stars 4.12k forks source link

Proposal: `$state.direct` for optional non-proxied state #10560

Open ThePaSch opened 7 months ago

ThePaSch commented 7 months ago

Describe the problem

It's a little irksome that this code, using the old Svelte 4 syntax, works as expected:

<script>    
    class FooBar {
        constructor() {
            this.foo = "abc";
            this.bar = { stuff: [] };
        }
    }

    let obj = new FooBar();

    function addStuff(key) {
        obj.bar.stuff = [...obj.bar.stuff, obj.bar.stuff.length]
    }
</script>

<p>stuff</p>
<ul>
    {#each obj.bar.stuff as stuff}
        <li>{stuff}</li>
    {/each}
</ul>

<button onclick={() => addStuff()}>add to bar</button>

but this, using Svelte 5's $state, doesn't:

<script>    
    class FooBar {
        constructor() {
            this.foo = "abc";
            this.bar = { stuff: [] };
        }
    }

    let obj = $state(new FooBar());

    function addStuff(key) {
        obj.bar.stuff = [...obj.bar.stuff, obj.bar.stuff.length]
    }
</script>

<p>stuff</p>
<ul>
    {#each obj.bar.stuff as stuff}
        <li>{stuff}</li>
    {/each}
</ul>

<button onclick={() => addStuff()}>add to bar</button>

...when the only change between those two is the addition of a rune to explicitly declare the FooClass instance as reactive (and therefore enabling runes mode). Essentially, reactivity no longer works on non-POJOs with Svelte 5's proxied reactivity, which has been a topic of discussion in the past. This is, as I understand, because using proxied reactivity on classes can introduce a lot of edge cases that could cause unexpected or wonky behavior, and I agree that it's a valid concern.

Now, the code from the second REPL above does still indeed work as expected in versions that predate the addition of proxied state to Svelte 5 (in particular, I've tried it on 5.0.0-next.15), which makes the loss of intuitive nested reactivity on non-POJOs in local scopes seem like a regression - it used to work, and it still works if you use legacy syntax, but as of right now, after the introduction of proxied state, if you would like to adapt your code to the new way of doing things, you're forced to refactor entire class libraries to use $state in their field declarations - thus tying them inextricably to Svelte and losing framework agnosticism - or write cumbersome boilerplate if you're using cross-framework code or dealing with external libraries you don't have full control over.

Describe the proposed solution

The proposal is to introduce a $state.direct rune - or $state.noproxy, or whichever name would be considered most suitable for this - the purpose of which would be to explicitly declare state to use whichever mechanism was in place to handle reactivity prior to the introduction of proxied state (and which, presumably, is still in place to handle reactivity in the case of Svelte 4 syntax - though do correct me if I'm wrong, as I'm not intricately familiar with the internal workings), and explicitly forgo said proxied state. This would massively simplify porting code that relies on reactive non-POJOs. The second REPL linked above would then look something like this:

<script>    
    class FooBar {
        constructor() {
            this.foo = "abc";
            this.bar = { stuff: [] };
        }
    }

    let obj = $state.direct(new FooBar());

    function addStuff(key) {
        obj.bar.stuff = [...obj.bar.stuff, obj.bar.stuff.length]
    }
</script>

<p>stuff</p>
<ul>
    {#each obj.bar.stuff as stuff}
        <li>{stuff}</li>
    {/each}
</ul>

<button onclick={() => addStuff()}>add to bar</button>

and provide the functionality one would expect when reading this code: that clicking the "add to bar" button would add a new entry to the listing of obj.bar array items.

Importance

would make my life easier

brunnerh commented 7 months ago

That would essentially be coarse grained reactivity (so maybe: $state.coarse), where any assignment to the variable or one of its (potentially deeply nested) properties invalidates the entire object.

Suspect that this could only work with state declared within the components, though. The code generated for the markup has to be adjusted according to what kind of signal is used. This breaks the portability of this specific rune.

The use of classes in v4 strikes me as odd. Given that functions that manipulate internal state don't work with the reactivity system, the only additional value they can provide is maybe a constructor.

dummdidumm commented 7 months ago

What's the use case behind this? As @brunnerh points out, it feels a bit weird to use classes like POJOs. And as also pointed out, it would not work when mutating an object outside the scope it was declared in, which will make the whole thing rather confusing - besides having three variants of doing state now.

I don't think a new $state subrune is the answer, but I'd like to understand the use case better to see if there's something else we can do.

ThePaSch commented 7 months ago

What's the use case behind this? As @brunnerh points out, it feels a bit weird to use classes like POJOs. And as also pointed out, it would not work when mutating an object outside the scope it was declared in, which will make the whole thing rather confusing - besides having three variants of doing state now.

The examples I gave are fairly rudimentary and it's true that I might as well just use POJOs in those specific scenarios - apologies, I could indeed have been more specific as to my particular use case.

Specifically, I'm working with a library that exports various classes representing data structures that are intended to be populated through deserialization from externally fetched JSON files, and those classes provide several functions - for instance, ones that handle certain aspects of said deserialization: they inherit from a base class that provides a base implementation of a deserializer, and then each subclass can override that with its own deserializer if required and/or desired. None of the functions those classes provide manipulate internal state (they are all conversions or return a transformation of the contained data in some way), but it's important that the provided JSON is specifically parsed into an instance of that class rather than a POJO. None of this is based on Svelte, which is why I can't just make the classes themselves reactive.

What I'm writing in Svelte(Kit) is a UI that allows for the creation of said JSON files through various forms and inputs. The most straightforward way to do this is to import that class library, create an instance of the class I want to represent, bind to its fields in my form inputs, and then serialize the resulting object. This works fine for primitives, but as demonstrated in the above REPLs, once I start manipulating arrays, things break in Svelte 5 (and still work fine in Svelte 4 or pre-proxy Svelte 5). I could, of course, simply create a POJO representing the class I want to create for each of my forms, but that seems very repetitious to me and amounts to a lot of boilerplate.

I understand that there are probably better ways to do all of the above, but for all intents and purposes, the above is what I'm "stuck" with. My issue lies with the fact that, in Svelte 4, everything works as I'd intuitively expect it to work, while in the current iteration of Svelte 5, the reason why this doesn't work isn't immediately obvious to me (even if there are valid technical explanations for it); the "vibe" is off, to jauntily co-opt language from the Svelte tenets.

I am, of course, aware that there are pitfalls involved here. I'm in no way implying that my proposal is the way to alleviate my particular conundrum; but I, at the very least, wanted to provide something of a proposal rather than just moaning about my issue with no semblance of proactivity. 😅

harrisi commented 7 months ago

The most obvious use case for this to me is using any kind of plain JavaScript library. I know I brought this up in another issue (#10263), but something like this: https://svelte.dev/repl/6352bf744f9646289d1014d1ee7ba681?version=4.2.10 doesn't work in svelte 5 with runes mode.

I'm just repeating the issue at this point and it doesn't seem to be as big of a problem as I think it is. I still don't know what the workaround for that would be, however.

This solution of a different rune feels odd, because its behavior would be the same for primitives and non-special objects, like {} and []. It could also just be used for those to mimic svelte 4, but then why even bother wrapping the values in $state()?

ThePaSch commented 7 months ago

Perhaps Svelte could provide a way to manually invalidate a reactive object - or one of its properties - then? Something like, say, $besmirch(obj) (name open for workshopping)? The reason this would be a rune and not a helper function is that the compiler could then substitute in a call to whichever code makes the magical things happen behind the scenes, plus, you could warn users at compile time that using it on a non-reactive object would be pointless; but, again, I can't say I know much about the inner workings of Svelte, so take all of that as the uninformed conjecture it is - this is all just spitballing.

Calling $besmirch(obj) would then functionally do the same thing as making changes to a proxied object and send a signal to Svelte, which can then invalidate it and update the UI accordingly with the (presumably) modified data. And one could go even deeper than that: $besmirch(obj.foo.bar) would only invalidate that particular sub-sub-object, which allows us to still profit from the wonders of fine-grained reactivity.

That way, we don't need an additional way to declare reactive state, and nothing would change (nor would $besmirch probably even be required) in the majority of cases - but in those edge cases where there is a snag for some reason, there'd be a tool to mitigate it.

So to go back to the example code from above, something like this:

<script>    
    class FooBar {
        constructor() {
            this.foo = "abc";
            this.bar = { stuff: [] };
        }
    }

    let obj = $state(new FooBar());

    function addStuff(key) {
        obj.bar.stuff = [...obj.bar.stuff, obj.bar.stuff.length];
                $besmirch(obj.bar.stuff);
    }
</script>

<p>stuff</p>
<ul>
    {#each obj.bar.stuff as stuff}
        <li>{stuff}</li>
    {/each}
</ul>

<button onclick={() => addStuff()}>add to bar</button>

...and, yes, $invalidate would probably be a better option for naming it.

harrisi commented 7 months ago

The thing about this is any rune option that mimics how svelte 4 use reassignment for reactivity, will simply be verbose svelte 4. Why not just keep anything that uses that behavior (i.e., svelte 4 behavior) in non-runes mode? It would, at best, keep things working as they do today, while maybe being able to get some improvements from the compiler changes (not sure if there are any, honestly), and at worst, cause users to split that behavior to new files in an awkward way.

I'm also not sure how any automated transition tool could handle this. That's a separate issue though.

ThePaSch commented 7 months ago

Why not just keep anything that uses that behavior (i.e., svelte 4 behavior) in non-runes mode?

Because, as I understand it, non-runes mode isn't going to be around forever. You also lose all of the benefits that runes bring with them if you're stuck in it for the entire component. But, yes, for now, non-runes mode is indeed the best workaround for my conundrum; I'm just not a big fan of having non-runes and runes components live together in the same project. Feels irritatingly inconsistent.

harrisi commented 7 months ago

I don't like the solution of having some parts of a svelte 5 project be in runes mode and some not (at least not as a long term solution) either. I'm saying that the changes to how svelte works so that primitives, objects made with {}, and [] are reactive, whereas new Foo() is not, is a regression that doesn't seem to have an ideal solution. If those primitives and special object literals are special but need to be wrapped in $state(), built-ins like Map and Set (if that happens, which it seems like it will) have a special import for reactivity, and classes have a special rune, $state.coarse(), for example, there's overlap and inconsistency.

ThePaSch commented 7 months ago

I'm saying that the changes to how svelte works so that primitives, objects made with {}, and [] are reactive, whereas new Foo() is not, is a regression that doesn't seem to have an ideal solution.

Yes, fully agreed. The biggest problem I have with this is that it wasn't intuitively clear why new Foo() isn't reactive, which is why I initially assumed I had run into a bug when things didn't work as expected. After reading into the comments on other issues here, as well as the original proxied state PR, I now do get it, but the fact is that this involves getting a grasp of what Svelte is actually doing behind the scenes when you declare something as reactive.

And, yes, there very likely won't be an ideal solution to this, but in cases such as these, I'd prefer something that's, at the very least, broadly workable - you never want perfect to be the enemy of good. The current suggested workaround of just modifying all classes that you want to be reactive to use $state - and therefore tying them inextricably to Svelte - is neither something that's always possible, nor something that's always desirable.

harrisi commented 7 months ago

What I'm saying is that if there's a way for classes to be coarsely reactive, why not use reassignment as the method for that, since it's what svelte 4 did. However, if that's the case, then you don't need a new rune, since primitives also use reassignment for reactivity, so there's no difference between these two:

let primitive = $state(0)
let klass = $state.coarse(new Foo()) // or whatever
primitive = 5
klass = klass

so why would the more verbose rune be necessary? Further, if the whole builtin thing happens, why would I need to have a deeply reactive Set using a custom import when I already use = for all my other classes. Then there's quickly this situation:

let primitive = $state(0)
let klass = $state.coarse(new Foo())
let obj = $state({ foo: 0, mutate() { this.foo++ } })
import { Set } from 'svelte/reactivity'
let set = new Set()
primitive = 5 // reactive
klass = klass // reactive
obj.mutate() // reactive
set.add(10) // reactive (if the builtin issue goes through as is)
obj = obj // also reactive
set = set // unclear, since the issue about reactive builtins is still open
// some method `mutate` similar to the one in `obj`
klass.mutate() // not reactive

The nice thing about svelte 4 is that these are all reactive in the same way:

let primitive = 0
let klass = new Foo()
let obj = { foo: 0, mutate() { this.foo++ } }
let set = new Set()
primitive = 5 // reactive
klass = klass // reactive
obj = obj // reactive
set = set // reactive
obj.mutate() // not reactive
set.add(10) // not reactive
// some method `mutate` similar to the one in `obj`
klass.mutate() // not reactive
trueadm commented 7 months ago

I'm strongly against us having a rune that emulates Svelte 4's assignment reactivity. If you assign a reactive value to the same thing then I don't think it should update as, it's not changed. Inventing a new rune to bring this behavior back isn't a good thing as the original pattern was wonky. I don't understand why an assignment can't be a fresh value or a clone of some older value if you're working immutably with $state.frozen.

harrisi commented 7 months ago

I just want to make sure I'm understanding correctly, there will not be any way to have this JavaScript in a file:

export class Foo {
  foo = 0
  incFoo() { this.foo++ }
}

and have that be used for reactive values in Svelte 5 runes mode, and in the future it won't be possible once the "legacy" Svelte 4 non-object reactivity is removed, correct? The solution would be to wrap individual values of the class (I think this is how this is done in other frameworks like Vue), rewrite it to use $state, or use a plain object?

alecStewart1 commented 7 months ago

I would to add something that might give a reason for having this, as it's a real world example.

If you're using the ArcGIS Maps SDK for Javascript with a framework who's reactivity is based on the use of Javascript's Proxy, you might run into issues.

I've ran into issues in Vue 3 where when some constructor or function in the ArcGIS Map SDK wants a regular Object and not a Proxy, and we've had to make use of markRaw on certain variables to make certain an Object is passed and not a Proxy. This happened when we were reactively creating FeatureLayers and Graphics.

harrisi commented 5 months ago

One workaround is actually to wrap the class in an object. It's not the prettiest, though.

dummdidumm commented 5 months ago

Using a class that wraps the data along with a version signal comes close to the original version. Updated example from OP.

class External {
    #data;
    #version = $state(0);

    constructor(data) {
        this.#data = data;
    }
    get data() {
        this.#version;
        return this.#data;
    }
    set data(_data) {
        this.#version++;
        this.#data = _data;
    }
    invalidate() {
        this.#version++;
    }
}
SikandarJODD commented 5 months ago

I found simpler version : Link

we can do this and solve the issue

<script>    
    class FooBar {
     bar = $state([{
         name:'bro',
         code:'js',
         id:1,
     }])
    }

    let obj = $state(new FooBar());

    function addStuff(key) {
        obj.bar= [...obj.bar,{name:'hello',code:'cpp',id:obj.bar.length+1}]
    }
</script>

<p>stuff</p>
<ul>
    {#each obj.bar as stuff}
        <li>{stuff.name} {stuff.code} {stuff.id}</li>
    {/each}
</ul>

<button onclick={() => addStuff()}>add to bar</button>
ThePaSch commented 2 months ago

Using a class that wraps the data along with a version signal comes close to the original version. Updated example from OP.

That does not look bad! Perhaps the External helper - or something like it - could ship with Svelte and get a mention in the docs; then it's almost as good as just dealing with the object directly.

Though I will say I'm still not entirely a fan of how much "background knowledge" is now necessary in many Svelte 5 workflows in general; the issue that spawned this thread would still be an example of it. Previously, you could write code that looks like it should work, and it worked as you expected - I'd reckon this aspect of Svelte was the main reason for why it was the king of DX. Now, you constantly need to worry about how Svelte achieves what it achieves in the background - for instance, you need to $state.snapshot when you send state to an outside consumer because the state is proxied, but you have to know that state is proxied in order to know do this, and if you don't, stuff might break in unexpected and obscure ways. Or, you need to use $state.is to compare a value to reactive state, which means there is now a distinction between the two where there didn't use to be - and if you don't know that, then you're going to wonder why your comparisons seem so wonky. Or, you now need to use specific Svelte-only Maps and Sets and Dates and somesuch if you want them to be reactive, which reduces framework agnosticity of all affected code. Or - of course - you would now need to use External whenever you want your code to react to values that aren't part of a POJO.

Every one of these examples is going to need a mention somewhere in the docs, adding sizeable API surface, and will add something you will need to mentally make a note of and keep in the back of your mind whenever you write Svelte code. It feels very un-Svelte-like, and I wouldn't be surprised to see many issues related to all of the above pop up once Svelte 5 is out and about.