Skip to content
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

feat: add $state.invalidate rune #15673

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from

Conversation

Ocean-OS
Copy link
Contributor

@Ocean-OS Ocean-OS commented Apr 3, 2025

This adds the $state.invalidate rune for forcing updates on a variable declared with $state or $state.raw, based on $state.opaque and closely related to #14520 (comment). If you have used Svelte 3 or 4, this is the equivalent of foo = foo, which currently doesn't have a Svelte 5 equivalent.
Now, while I don't quite know the exact reasons that $state.opaque was rejected (the most info we have is this), I have noticed some ergonomic issues, such as:

  • A variable that needs a force update has to have its declaration changed to a destructured pattern with $state.opaque like so:
//before:
let thing = $state(new Class());
//after:
let [thing, invalidateThing] = $state.opaque(new Class());
  • There's no good way to name the invalidate function. With similar functions outside of Svelte like React's useState, the common convention is [thing, setThing], but with $state.opaque, there wasn't a clear non-verbose way to name the invalidator.

Meanwhile, $state.invalidate can be used without these caveats on any $state or $state.raw variable:

let counter = $state(new Counter()); //imagine `Counter` as being a class from an NPM package or a native class
function increment() {
   counter.increment(); // counter's internal state has changed, but Svelte has no way of knowing that
   $state.invalidate(counter); 
}
$effect(() => console.log(`Count is ${counter.count}`));

Closes #14520

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 with feat:, fix:, chore:, or docs:.
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.
  • If this PR changes code within packages/svelte/src, add a changeset (npx changeset).

Tests and linting

  • Run the tests with pnpm test and lint the project with pnpm lint

Copy link

changeset-bot bot commented Apr 3, 2025

🦋 Changeset detected

Latest commit: d4394c5

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

This PR includes changesets to release 1 package
Name Type
svelte Minor

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

Copy link
Contributor

github-actions bot commented Apr 3, 2025

Playground

pnpm add https://pkg.pr.new/svelte@15673

@Thiagolino8
Copy link

Thiagolino8 commented Apr 3, 2025

I prefer this version as it resembles existing APIs like https://vuejs.org/api/reactivity-advanced.html#triggerref
And then, unlike $state.opaque, this one can be used in class properties.

@gyzerok
Copy link

gyzerok commented Apr 3, 2025

We are now looking closely at how we can utilize writable deriveds to our benefit which have led up to the question on how to reset a derived back to its computed value. Which leads me to question if there should be also $derived.invalidate rune. What do you think?

@dummdidumm
Copy link
Member

What's the use case of reverting a value back to its computed value, and is this something you could write a wrapper for? Something like

let prev;
let value = $derived.by(() => {
  prev = value;
  // ...
});
// ...
value = prev;

@gyzerok
Copy link

gyzerok commented Apr 3, 2025

@dummdidumm we are now trying to draft how we would write our models to support optimistic updates and seems like writable derived is exactly a perfect fit for it. We are building a real-time application and are looking into supporting offline-mode to a certain degree.

So imagine the same example that is described in the docs with the slight change that we can schedule multiple optimistic updates before server responds. Now if the server responds with error on the first update what we want is to reset the value to its "confirmed" state and try to reapply second, third, etc optimistic updates again.

While the way you suggest to solve it would work it'll be kind of impractical when we have multiple models with tens of fields - it'd be simply a lot of code. The reasoning is sort of the same as with writable deriveds. While you can achieve writable deriveds with $state and $effect having them seems simpler at scale.

class Model extends AbstractModel {
  // This is a confirmed value that is consistent with the server state.
  // Here I use only one field as an example, but lets imagine there are 10s of them,
  // and a single change could involve updating multiple fields.
  private serverCounter = $state(0)
  
  // this value we will use for UI and will optimistically update
  counter = $derived(this.serverCounter)

  increment(amount: number): void {
    // I don't have an exact API here yet, we are still looking at how to best implement it.
    // However lets imagine we store this functions in some collection,
    // then we apply optimistic updates immediately, but run server queries in sequence.
    // If the request responds with an error, we reset all the deriveds on the model
    // and reapply remaining proposals again, then we send the second request and so on.
    // Here $derived.invalidate would come in handy.
    this.propose(() => {
      this.count += amount

      return async () => {
        this.serverCount = await fetch()
      }
    })
  }
}

// somewhere in UI clicks button multiple times and we call increment multiple times
model.increment(1)
model.increment(3)
model.increment(2)

The idea here is to have something conceptually similar to what is written in this article, without patches and snapshots though. If you know of any better ways to do this in Svelte and have time/energy to share would also appreciate any ideas.

@dummdidumm
Copy link
Member

If you have a definite variable that contains the "real" state, and you have a way to know it errors (which you need anyway else how do you know how to call $derived.invalidate) why not do this.counter = this.#serverCounter then?

@gyzerok
Copy link

gyzerok commented Apr 3, 2025

Yeah, you are right. Ideally we still hope that we can find a way to make a somewhat generic solution, so we could apply it to any model. Of course given the lack of decorators and ability to iterate over class properties in Svelte it might be not achievable, but lets see.

Anyway, thank you for looking into this. Perhaps it'd be better for me to open a separate issue with better example if we end up needing something like $derived.invalidate in the end :)

@gyzerok
Copy link

gyzerok commented Apr 4, 2025

After todays drafting session we were able to restructure our idea such that we can still keep it generic without the need to manually invalidate for $derived, however $state.invalidate would come in very handy. So eagerly waiting for it to land.

@gyzerok
Copy link

gyzerok commented Apr 9, 2025

I've also noticed that the follwing trick helps solving the same problem:

class Box {
	value;
	constructor(initial) {
		this.value = initial;
	}
}


class Counter {
	count = $state.raw({ inner: new Box(0) });
	increment() {
		this.count.inner.value += 1;
                this.count = { inner: this.count.inner }
	}
}

Not as nice as the PRs solution, but solvable without any additions to the API

@Thiagolino8
Copy link

Is it possible to invalidate only one property of an object?

const counters = $state({
  count1: 0,
  count2: new Counter()
})

$effect(() => console.log(counters.count1))
$effect(() => console.log(counters.count2.current)) // Only trigger this one

counters.count2.current++
$state.invalidate(counters.count2)

If not, would not it make sense to limit this rune to be used with only $state.raw?

@Ocean-OS
Copy link
Contributor Author

Ocean-OS commented Apr 9, 2025

I could probably make it do that, but that'd require basically treating it like an effect (and wrapping it in an arrow function), getting the source that corresponds to that property, and invalidating that.

@Thiagolino8
Copy link

Just to clarify, I don't think making it possible to invalidate properties is worth the effort
My point is that if $state.invalidate wasn't designed to use the specific deep fine grain reactivity features of $state then it should be limited to being used with $state.raw

@Ocean-OS
Copy link
Contributor Author

Ocean-OS commented Apr 9, 2025

If I were to try to implement it, it'd be in a separate PR commit so I could easily revert it.
I honestly would be curious to see if I could do it, and if it would work well.

@Ocean-OS
Copy link
Contributor Author

Ocean-OS commented Apr 11, 2025

I've added the ability to invalidate individual properties of a $state proxy (in a pretty performant/simple way too), feel free to try it out and see if there are any bugs.
If there are too many bugs, some sort of confusion, or little reason to have it, I'll revert it.

@Thiagolino8
Copy link

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

add a way to force update a state variable
4 participants