svelte: Reactive blocks run only once per tick, losing changes and allowing fast pace apps to get out of sync
Describe the bug
TLDR: Reactive blocks that need to stay up to speed with multiple state changes during the same tick basically break the application in subtle, hard to reason about ways (because things “randomly” go out of sync). This bug breaks the basic guarantee of reactivity.
- This bug goes all the way back to version 3.0.0.
- I am giving it high severity because at least for us, it is a huge factor in deciding whether to use Svelte for future projects and I think it will be the same for other teams the encounter these behaviours.
More Info and context (skip to the next section for REPLs):
- My team is using Svelte in production to build some highly graphic and complex interactive experiences (game like), which sometimes mean rapid state updates and ideally a lot of reactive blocks to keep different pieces separate.
- This bug happens with stores as well, even though store subs behave differently as they do fire multiple times per tick (which is a good thing). This adds to the confusion and overall feeling of inconsistency (people in my team initially thought stores are causing this issue).
- Anything async also behaves differently (because it is not in the same tick), it leads to consistent state and potentially introduces infinite loops in a surprising manner (because the code “worked just fine” because of this bug when there was no async behaviour) which adds to the confusion and inconsistency of the DX.
- Because of how puzzling it is when it happens in a complex app (I change state but it doesn’t render), devs in my team were constantly puzzled by why one way of doing something works while another sends the app to hell. We started thinking about reactive blocks as strange foot guns 😢 . Now that I know the root cause we can find (ugly) ways around it by being extra vigilant at all times, but I am sure others will encounter it too.
- I was told by some on #6730 (I thought it is the same issue at first) that this is a built in protection from possible infinite loops. If that’s the case this is at minimum a bug in the documentation and in my opinion not a good design decision for the framework. App consistency is more important and this defence mechanism is very limited and confusing anyway. It also means svelte is not well suited for the type of apps that could benefit the most from its full feature-set, performance, small size and elegance (== complex apps built by devs who can easily defend from infinite loops with normal code or the conditionals of the reactive blocks).
- I know that changing this can break existing apps that rely on it. Could this behaviour be made opt-out via some compiler option or otherwise (Ideally something we can pass in via the rollup config)?
- I am willing to allocate resources to fixing it if that helps (and if we can get some guidance for where to start from)
Reproduction
Expected behaviour: isSmallerThan10 should be false. Actual behaviour: isSmallerThan10 stays true, which is out of sync with the app state (and breaks the contract of reactivity)
Logs
N/A
System Info
System:
OS: macOS 11.5.2
CPU: (16) x64 Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
Memory: 39.63 GB / 64.00 GB
Shell: 5.8 - /bin/zsh
Binaries:
Node: 12.16.2 - ~/.nvm/versions/node/v12.16.2/bin/node
Yarn: 1.22.10 - ~/.nvm/versions/node/v12.16.2/bin/yarn
npm: 7.16.0 - ~/.nvm/versions/node/v12.16.2/bin/npm
Browsers:
Brave Browser: 86.1.15.76
Chrome: 93.0.4577.82
Firefox: 89.0.2
Safari: 14.1.2
Severity
blocking all usage of svelte
About this issue
- Original URL
- State: closed
- Created 3 years ago
- Reactions: 18
- Comments: 37 (22 by maintainers)
Note that your REPL example can be simplified even further: the same behavior is seen without needing an object property. In other words, when you write
let count = {a:1};, you could demonstrate the same behavior even more simply withlet count = 1, as follows:The docs say that
onDestroy“Schedules a callback to run immediately before the component is unmounted.” So you can put it inside a helper function and that’s okay, because when that helper function is being run, it’s always being run by a component, and it will add that callback to that component’sonDestroylist.This will be fixed in Svelte 5. The runtime is more consistent now with listening to updates. To not break backwards-compatibility, the
$:-behavior of running only once is preserved, but using$derivedand$effectinstead will yield the correct results.This may got a little lost in the other thread, but here’s another approach by Rich for a simulated
useEffectwhich does not need to be used with stores: https://svelte.dev/repl/0c9cd8c29c5043eea89bd9c6eb4f279a?version=3.42.6