Skip to content

Conversation

Rich-Harris
Copy link
Member

@Rich-Harris Rich-Harris commented Sep 25, 2025

The $effect.pending rune tells you how many await expressions are unresolved in the current boundary, which is useful for providing feedback to the user that something is happening.

This PR adds a $state.eager(value) rune that also updates immediately, regardless of whether the rest of the UI is suspending while async work happens. A bit like React's useOptimistic except much less confusing and you can use it with state you don't 'own' (and without having to pass an optimistic version of that state around).

Quick primer: when a piece of state changes, and an await expression depends on that state, Svelte doesn't update the UI to reflect the state change until the await expression has resolved. Otherwise it's a total free-for-all — if you have one component that does await fetchPost(params.slug) and another one that does await fetchComments(params.slug), the post and the comments will be rendered at different times. Or, to take a contrived example that makes it clear, in a case like this...

<p>1 * {n} = {await multiply(n, 1)}</p>

...if n changes from 1 to 2 the UI will show 1 * 2 = 1. This is a bad default.

Other frameworks solve this with things like useTransition. Svelte's view is that you want to use a transition in 99% of cases, and so making it opt-in rather than opt-out is the wrong choice. But right now we don't have an opt-out mechanism.

$state.eager(...) is that mechanism. It allows you to always show the latest version in one specific part of your UI — for example, in the button that changes some state.

Caveats:

  • Not battle-tested
  • Needs tests

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 Sep 25, 2025

🦋 Changeset detected

Latest commit: 2e5c15f

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

Playground

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

@Thiagolino8
Copy link

The problem with only being able to use it in the template is that you need to be vigilant about using it in all places your state shown if you want to avoid tearing of displays of the same state
In your example, the counter has one value on the button and another in the template
In my opinion, tearing within the same state is much worse than tearing between a state and its derivatives
It seems to me like something that would easily generate inconsistent UIs.
And currently there's an error if you try to use it more than once

@dummdidumm
Copy link
Member

dummdidumm commented Sep 26, 2025

yeah it 100% must be usable in the script tag aswell (e.g. inside $derived)

@Rich-Harris
Copy link
Member Author

People have doubts about the name. So do I. I reused $effect.pending() because it was lying around already, but it doesn't do a great job of communicating what this is. And since we go out of our way to discourage the use of $effect, it might be confusing to people that we're promoting this as the way to show immediate feedback upon changes.

So what should we rename it, and should we also rename $effect.pending()? $state.pending()? $state.latest(...)?

@7nik
Copy link
Contributor

7nik commented Sep 26, 2025

I'm thinking of nonblocking, immediate, and latest.

@Ocean-OS
Copy link
Member

$derived.latest would be great if we restricted the expression to be pure. Even though it doesn't function the same as a $derived under the hood, it seems pretty similar to me.

@Ocean-OS
Copy link
Member

I think I've found a bug: if you click the counter fast enough the text can show incorrect results.
Screenshot_20250929_160324_Chrome.jpg

@Serator
Copy link

Serator commented Sep 30, 2025

I encountered a similar problem. Should this be moved to a separate issue?

iShot_2025-09-30_10.14.18.mp4

@svelte-docs-bot
Copy link

* runtime-first approach

* revert these

* type safety, lint

* fix: better input cursor restoration for `bind:value` (#16925)

If cursor was at end and new input is longer, move cursor to new end

No test because not possible to reproduce using our test setup.

Follow-up to #14649, helps with #16577

* Version Packages (#16920)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* docs: await no longer need pending (#16900)

* docs: link to custom renderer issue in Svelte Native discussion (#16896)

* fix code block (#16937)

Updated code block syntax from Svelte to JavaScript for clarity.

* fix: unset context on stale promises (#16935)

* fix: unset context on stale promises

When a stale promise is rejected in `async_derived`, and the promise eventually resolves, `d.resolve` will be noop and `d.promise.then(handler, ...)` will never run. That in turns means any restored context (via `(await save(..))()`) will never be unset. We have to handle this case and unset the context to prevent errors such as false-positive state mutation errors

* fix: unset context on stale promises (slightly different approach) (#16936)

* slightly different approach to #16935

* move unset_context call

* get rid of logs

---------

Co-authored-by: Rich Harris <[email protected]>

* fix: svg `radialGradient` `fr` attribute missing in types (#16943)

* fix(svg radialGradient): fr attribute missing in types

* chore: add changeset

* Version Packages (#16940)

* Version Packages

* Update packages/svelte/CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Rich Harris <[email protected]>

* chore: simplify `batch.apply()` (#16945)

* chore: simplify `batch.apply()`

* belt and braces

* note to self

* unused

* fix: don't rerun async effects unnecessarily (#16944)

Since #16866, when an async effect runs multiple times, we rebase older batches and rerun those effects. This can have unintended consequences: In a case where an async effect only depends on a single source, and that single source was updated in a later batch, we know that we don't need to / should not rerun the older batch.

This PR makes it so: We collect all the sources of older batches that are not part of the current batch that just committed, and then only mark those async effects as dirty which depend on one of those other sources. Fixes the bug I noticed while working on #16935

* fix: ensure map iteration order is correct (#16947)

quick follow-up to #16944
Resetting a map entry does not change its position in the map when iterating. We need to make sure that reset makes that batch jump "to the front" for the "reject all stale batches" logic below. Edge case for which I can't come up with a test case but it _is_ a possibility.

* feat: add `createContext` utility for type-safe context (#16948)

* feat: add `createContext` utility for type-safe context

* regenerate

* Version Packages (#16946)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* chore: Remove annoying sync-async warning (#16949)

* fix

* use `$state.eager(value)` instead of `$effect.pending(value)`

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Hyunbin Seo <[email protected]>
Co-authored-by: Ben McCann <[email protected]>
Co-authored-by: Rich Harris <[email protected]>
Co-authored-by: Hannes Rüger <[email protected]>
Co-authored-by: Elliott Johnson <[email protected]>
@Rich-Harris Rich-Harris changed the title feat: $effect.pending(value) feat: $state.eager(value) Oct 14, 2025
@Rich-Harris
Copy link
Member Author

Couple of things that don't work:

  • referencing the latest value inside an {#if ...} block link
  • using $state.eager(...) inside a boundary link

As a starting point we need to decouple this logic from boundaries. Not sure if that'll fix both issues though

@Rich-Harris
Copy link
Member Author

Fixed the first thing, opened #16954 for the second as I think it's a manifestation of a pre-existing bug and shouldn't prevent us from shipping this

@dummdidumm
Copy link
Member

dummdidumm commented Oct 15, 2025

Should we allow people to do

<script>
  let foo = $state(...);
  let eager = $state.eager(foo);
</script>

Because I'm likely to put inside a variable as soon as I need it more than once, which may be common, and $derived($state.eager(foo)) is very clunky.

Essentially because we can detect in which position $state.eager is used we can transform it to $derived($state.eager(foo)) under the hood.

Potential counter argument: What if you don't want this to be reactive, i.e. you want the value only once? Then you'd have no straightforward-way to do it. But I can't imagine why you wouldn't.

Aside: The runtime-side of things could use a few comments.

@dummdidumm
Copy link
Member

dummdidumm commented Oct 15, 2025

While not user-visible something feels off here. If I put console.logs in I can see that it logs more often than it should, especially if the value doesn't need to be eager (I wonder if there should be a guard that if there's nothing in the map we just return fn() right away). But more concerning the value goes back to the stale one for once while the eager one is displaying.

There's also (probably more theoretical) question about whether or not effects should run eagerly, right now they do

@Rich-Harris
Copy link
Member Author

We can stash the value from when it runs inside the inspect effect, and move the batch_values = null to that callsite so that it always uses the latest value (albeit at the slightly increased cost of needing to wrap every individual fn() in a try-finally, rather than the global flushSync()). That will make it run fewer times. AFK so can't make that change right now.

Why wouldn't you want effects to run eagerly if they're using an eager value?

@Rich-Harris
Copy link
Member Author

Re the shorthand proposal, I don't love the ambiguity it introduces, though it's hard to imagine a case where you wouldn't want that behaviour.

Another option would be to have a $derived.eager(...) shorthand, though that's an expansion of the surface area. If we were to make $state.eager(...) behave that way in declaration position we would need to ship it with this PR because it would be a breaking change to add it later. Of course, if we discover it's a bad idea it would also be a breaking change to remove it... So a later addition of $derived.eager is the more conservative option

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.

6 participants